Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions agents/codelayer/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,14 @@ function mergeHooks(base: ReturnType<typeof createAgentFilesystemHooks>, hooks?:
}
}

function createForkConfig(tools: Record<string, Tool<any, any>>) {
const { agent: _agentTool, ...nonDelegatingTools } = tools
return {
tools,
fork: { tools: nonDelegatingTools },
}
}

export async function createCodelayerAgent(opts: CodelayerAgentOptions): Promise<Agent> {
const {
model,
Expand Down Expand Up @@ -468,6 +476,7 @@ export async function createCodelayerAgent(opts: CodelayerAgentOptions): Promise
stopWhen: [doomLoop(3)],
providerOptions,
promptCacheKey,
fork: createForkConfig(tools),
})
}

Expand Down Expand Up @@ -515,6 +524,7 @@ export async function createCodelayerAgent(opts: CodelayerAgentOptions): Promise
stopWhen: [doomLoop(3)],
providerOptions,
promptCacheKey,
fork: createForkConfig(tools),
})
}

Expand Down
24 changes: 16 additions & 8 deletions agents/codelayer/test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ function getAgentConfig(agent: object) {
return agent as {
model?: LanguageModel
tools?: Record<string, unknown>
forkConfig?: {
tools: Record<string, unknown>
fork?: { tools: Record<string, unknown> }
}
system?: string | string[]
hooks?: AgentConfig['hooks']
providerOptions?: Record<string, unknown>
Expand Down Expand Up @@ -686,6 +690,8 @@ describe('createCodelayerAgent', () => {
expect(config.tools?.write).toBeDefined()
expect(config.tools?.web_fetch).toBeDefined()
expect(config.tools?.agent).toBeDefined()
expect(config.forkConfig?.tools.agent).toBe(config.tools?.agent)
expect(config.forkConfig?.fork?.tools.agent).toBeUndefined()
expect(config.system?.length).toBeGreaterThan(0)
// glob, grep, list removed - agent uses bash for file discovery
expect(config.tools?.list).toBeUndefined()
Expand Down Expand Up @@ -831,10 +837,10 @@ describe('createCodelayerAgent', () => {
context7ApiKey: 'context7-test-key',
})
const config = getAgentConfig(agent)
const subagent = config.tools?.agent as { description?: string } | undefined
const subagents = getSubagents(config.tools?.agent)

expect(subagent?.description).toContain('library-researcher')
expect(subagent?.description).toContain('rpi:implementer-agent')
expect(subagents.some((subagent) => subagent.name === 'library-researcher')).toBe(true)
expect(subagents.some((subagent) => subagent.name === 'rpi:implementer-agent')).toBe(true)
})

test('creates an rlm codex agent without bash and with apply_patch', async () => {
Expand All @@ -852,6 +858,8 @@ describe('createCodelayerAgent', () => {
expect(config.tools?.grep).toBeUndefined()
expect(config.tools?.glob).toBeUndefined()
expect(config.tools?.agent).toBeDefined()
expect(config.forkConfig?.tools.agent).toBe(config.tools?.agent)
expect(config.forkConfig?.fork?.tools.agent).toBeUndefined()
expect(config.tools?.web_fetch).toBeDefined()
})

Expand Down Expand Up @@ -1213,7 +1221,6 @@ describe('createCodingSubagentTool', () => {
const tool = getAgentConfig(agent).tools?.agent as Tool<any, any> | undefined

expect(tool?.input.safeParse({ prompt: 'inherit and inspect' }).success).toBe(true)
expect(tool?.description).toContain('fork all eligible calling-agent conversation')
})

test('creates the standard subagent tool wrapper', async () => {
Expand All @@ -1223,9 +1230,10 @@ describe('createCodingSubagentTool', () => {
})

expect(tool.name).toBe('subagent')
expect(tool.description).toContain('general-purpose')
expect(tool.description).toContain('rpi:implementer-agent')
expect(tool.description).toContain('rpi:codebase-locator')
const subagents = getSubagents(tool)
expect(subagents.some((subagent) => subagent.name === 'general-purpose')).toBe(true)
expect(subagents.some((subagent) => subagent.name === 'rpi:implementer-agent')).toBe(true)
expect(subagents.some((subagent) => subagent.name === 'rpi:codebase-locator')).toBe(true)
})

test('hard-codes multimodal read for coding subagents', async () => {
Expand Down Expand Up @@ -1281,7 +1289,7 @@ describe('createCodingSubagentTool', () => {
context7ApiKey: 'context7-test-key',
})

expect(tool.description).toContain('library-researcher')
expect(getSubagents(tool).some((subagent) => subagent.name === 'library-researcher')).toBe(true)
})
})

Expand Down
22 changes: 1 addition & 21 deletions agents/codelayer/test/coding-subagent-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('createCodingSubagentTool', () => {
system: 'test system prompt',
})
const input = tool.input as any
const shape = input.shape as Record<string, { description?: string }>
const shape = input.shape as Record<string, unknown>

expect(Object.keys(shape)).toEqual([
'description',
Expand All @@ -50,24 +50,6 @@ describe('createCodingSubagentTool', () => {
])
expect(input.safeParse({ prompt: 'delegate this' }).success).toBe(true)
expect(input.safeParse({ prompt: 'delegate this', unknown: true }).success).toBe(false)
expect(shape.description?.description).toBe('Short description of the subagent task.')
expect(shape.prompt?.description).toBe(
'Task for the subagent. Custom-role tasks must be self-contained because they do not inherit the conversation.',
)
expect(shape.agent_id?.description).toBe(
'Continue an existing subagent using an ID from an earlier result. Do not combine with fork_turns or subagent_type.',
)
expect(shape.fork_turns?.description).toBe(
'Conversation to inherit: "all", "none", or a positive integer string such as "3". Omitted means "all". Do not combine with agent_id or subagent_type.',
)
expect(shape.subagent_type?.description).toBe(
'Start a registered specialist without inheriting the calling agent conversation. Do not combine with agent_id or fork_turns.',
)
expect(shape.skill?.description).toBe('Optional skill to preload into the subagent.')
expect(tool.description).toContain('Omit agent_id, fork_turns, and subagent_type')
expect(tool.description).toContain('Set subagent_type to start a fresh registered specialist')
expect(tool.description).toContain('Every terminal result returns an agent_id')
expect(tool.description).toContain('completion, error, or interruption')
expect(tool.subagents.every((agent) => agent.resumable === true)).toBe(true)
})

Expand All @@ -83,7 +65,6 @@ describe('createCodingSubagentTool', () => {
tool.input.safeParse({ description: 'small task', prompt: 'work', subagent_type: 'general-purpose' }).success,
).toBe(true)
expect(tool.input.safeParse({ prompt: 'continue', agent_id: 'prior-child' }).success).toBe(true)
expect(tool.description).toContain('Omit agent_id, fork_turns, and subagent_type')
expect(tool.subagents.every((agent) => agent.resumable === true)).toBe(true)
})

Expand All @@ -98,7 +79,6 @@ describe('createCodingSubagentTool', () => {

expect(OUTLINE_IMPLEMENTER_AGENT_NAME).toBe('rpi:outline-implementer-agent')
expect(subagents).toHaveLength(1)
expect(subagents[0]?.description).toContain('Implements structure outlines')
})

test('shares the configured skill tool with every sub-agent', async () => {
Expand Down
13 changes: 12 additions & 1 deletion packages/agentlayer-core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export type ProviderOptions = Parameters<typeof streamText>[0]['providerOptions'
export type ProviderOptionsFactory = (ctx: { runId: string; promptCacheKey?: string }) => ProviderOptions
type StreamPart = TextStreamPart<any>

export interface ForkAgentConfig {
tools: Record<string, Tool<any, any>>
fork?: ForkAgentConfig
}

function isReasoningOnlyAssistantMessage(message: ModelMessage): boolean {
if (message.role !== 'assistant' || !Array.isArray(message.content)) return false
let hasReasoning = false
Expand Down Expand Up @@ -95,6 +100,8 @@ export interface AgentConfig<TTools extends Record<string, Tool<any, any>> = Rec
contextWindowLimit?: number
/** Provider-neutral compaction policy. Omitted means enabled with defaults. */
autoCompact?: AutoCompactConfig
/** Runtime configuration for fork children. Nested values configure later descendants. */
fork?: ForkAgentConfig
/** Called when an approval is requested. Fires before the event is pushed to the iterator. Observe-only, errors swallowed. */
onApprovalRequested?: (
approval: ApprovalRequest,
Expand Down Expand Up @@ -297,6 +304,7 @@ export class Agent<TTools extends Record<string, Tool<any, any>> = Record<string
private contextWindowLimit: number | undefined
private promptCacheKey: string
private autoCompact: AutoCompactConfig | undefined
private forkConfig: ForkAgentConfig | undefined

private modelProvider: ModelProvider

Expand All @@ -315,6 +323,7 @@ export class Agent<TTools extends Record<string, Tool<any, any>> = Record<string
this.onApprovalRequested = config.onApprovalRequested
this.contextWindowLimit = config.contextWindowLimit
this.autoCompact = config.autoCompact
this.forkConfig = config.fork
this.hooks = config.hooks
this.modelProvider = config.modelProvider ?? new ModelProvider()
}
Expand All @@ -341,10 +350,11 @@ export class Agent<TTools extends Record<string, Tool<any, any>> = Record<string
}

private createForkAgent(): Agent {
const forkConfig = this.forkConfig
return new Agent({
model: this.model,
system: this.system,
tools: this.tools,
tools: forkConfig?.tools ?? this.tools,
toolChoice: this.toolChoice,
providerOptions: this.providerOptions,
maxSteps: this.maxStepsLimit,
Expand All @@ -357,6 +367,7 @@ export class Agent<TTools extends Record<string, Tool<any, any>> = Record<string
hooks: this.hooks,
promptCacheKey: this.promptCacheKey,
autoCompact: this.autoCompact,
fork: forkConfig?.fork,
})
}

Expand Down
1 change: 1 addition & 0 deletions packages/agentlayer-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {
type AgentConfig,
type CompactOptions,
type FinishReason,
type ForkAgentConfig,
type ProviderOptions,
type ProviderOptionsFactory,
type RunOptions,
Expand Down
14 changes: 14 additions & 0 deletions packages/agentlayer-core/src/tools/subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,22 @@ export async function deriveChildPromptCacheKey(parentKey: string, toolCallId: s
function expandedDescription(agentList: string): string {
return `Launch an isolated subagent.

Do not spawn subagents unless the user or applicable AGENTS.md or skill instructions explicitly ask for subagents, delegation, or parallel agent work. Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do not count as permission to spawn.

Only call this tool for a concrete, bounded, yet non-trivial subtask.

When not to use subagents:
- If you want to read a specific file path.
- If you are searching for code within a specific file or set of two or three files.
- If no available agent is a good fit for the task; use other tools directly.
- Do not use rpi: subagents for trivial research tasks.

When using subagents, use multiple subagents in parallel to parallelize discrete, bounded tasks.

Omit agent_id, fork_turns, and subagent_type to fork all eligible calling-agent conversation into a new child. Set fork_turns to "all", "none", or a positive integer string to control inherited conversation. Set subagent_type to start a fresh registered specialist with no inherited conversation. Every terminal result returns an agent_id; pass it with a follow-up prompt to continue that exact child after completion, error, or interruption.

Using fork_turns or omitting subagent_type spawns a subagent with the same tools as you and the ability to spawn its own subagents.

Registered specialists:
${agentList}`
}
Expand Down
13 changes: 0 additions & 13 deletions packages/agentlayer-core/test/structured-output-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,6 @@ describe('createStructuredOutputTool', () => {
expect(tool.name).toBe('structured_output')
})

test('includes generated JSON schema in the description', () => {
const tool = createStructuredOutputTool(
z.object({
name: z.string(),
age: z.number(),
}),
)

expect(tool.description).toContain('JSON Schema')
expect(tool.description).toContain('"name"')
expect(tool.description).toContain('"age"')
})

test('serializes typed data to JSON', async () => {
const tool = createStructuredOutputTool(z.object({ answer: z.number() }))
const result = await tool.execute({ data: { answer: 42 } }, makeToolContext())
Expand Down
50 changes: 31 additions & 19 deletions packages/agentlayer-core/test/subagent-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,25 +94,6 @@ function createLocalReadTool(cwd: string) {
// ── Tests ────────────────────────────────────────────────────────────────────

describe('createSubagentsTool', () => {
test('description includes all registered agent names', () => {
const childAgent = new Agent({
model: mockModel([assistantText('hi')]),
tools: { echo: echoTool },
})

const tool = createSubagentsTool({
agents: [
{ name: 'researcher', description: 'Deep codebase research', agent: childAgent },
{ name: 'implementer', description: 'Implement from a plan', agent: childAgent },
],
})

expect(tool.description).toContain('researcher')
expect(tool.description).toContain('Deep codebase research')
expect(tool.description).toContain('implementer')
expect(tool.description).toContain('Implement from a plan')
})

test('invalid subagent_type returns error in tool result', async () => {
const childAgent = new Agent({
model: mockModel([assistantText('hi')]),
Expand Down Expand Up @@ -971,6 +952,37 @@ describe('forking subagent tool', () => {
})
})

test('uses the configured fork chain to remove delegation from grandchildren', async () => {
const subagent = createForkingSubagentsTool({ agents: [] })
const delegatingTools = { agent: subagent }
const root = new Agent({
model: mockModel([
assistantWithToolCall('agent', { prompt: 'delegate to a child' }),
assistantWithToolCall('agent', { prompt: 'delegate to a grandchild' }),
assistantWithToolCall('agent', { prompt: 'attempt a fourth level' }),
assistantText('child completed after the grandchild error'),
assistantText('root completed'),
]),
tools: delegatingTools,
fork: {
tools: delegatingTools,
fork: { tools: {} },
},
})

const result = await root.run({ state: startState([userMessage('start')]) }).result

expect(result.finishReason).toBe('complete')
const child = Object.values(result.state.terminalChildren ?? {})[0]
const grandchild = Object.values(child?.state.terminalChildren ?? {})[0]
expect(grandchild?.lastOutcome).toBe('error')
expect(grandchild?.state.messages.at(-1)).toMatchObject({
role: 'assistant',
content: [{ type: 'tool-call', toolName: 'agent' }],
})
expect(grandchild?.state.terminalChildren).toBeUndefined()
})

test('does not recursively redispatch when the triggering parent instruction asks for a subagent', async () => {
const triggeringInstruction = 'call a subagent to investigate this problem'
const delegatedPrompt = 'Inspect the fork request and report what you find.'
Expand Down
23 changes: 0 additions & 23 deletions packages/agentlayer-core/test/tool-interfaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,26 +749,3 @@ describe('normalizeEscapes', () => {
expect(normalizeEscapes('\\n\\n\\t')).toBe('\n\n\t')
})
})

// ─── define() with description override ───────────────────────────────────────

describe('define() with description override', () => {
test('EditTool.define() accepts custom description', () => {
const tool = EditTool.define(async () => ({ content: '', matchCount: 0 }), {
description: 'Custom edit description',
})
expect(tool.description).toBe('Custom edit description')
})

test('WriteTool.define() uses default description when no override', () => {
const tool = WriteTool.define(async () => 'ok')
expect(tool.description).toBe('Write content to a file, creating it if it does not exist')
})

test('ApplyPatchTool.define() accepts custom description', () => {
const tool = ApplyPatchTool.define(async () => 'ok', {
description: 'Apply Codex patches',
})
expect(tool.description).toBe('Apply Codex patches')
})
})
Loading
Loading