diff --git a/agents/codelayer/src/agent.ts b/agents/codelayer/src/agent.ts index b815623..5550e11 100644 --- a/agents/codelayer/src/agent.ts +++ b/agents/codelayer/src/agent.ts @@ -354,6 +354,14 @@ function mergeHooks(base: ReturnType, hooks?: } } +function createForkConfig(tools: Record>) { + const { agent: _agentTool, ...nonDelegatingTools } = tools + return { + tools, + fork: { tools: nonDelegatingTools }, + } +} + export async function createCodelayerAgent(opts: CodelayerAgentOptions): Promise { const { model, @@ -468,6 +476,7 @@ export async function createCodelayerAgent(opts: CodelayerAgentOptions): Promise stopWhen: [doomLoop(3)], providerOptions, promptCacheKey, + fork: createForkConfig(tools), }) } @@ -515,6 +524,7 @@ export async function createCodelayerAgent(opts: CodelayerAgentOptions): Promise stopWhen: [doomLoop(3)], providerOptions, promptCacheKey, + fork: createForkConfig(tools), }) } diff --git a/agents/codelayer/test/agent.test.ts b/agents/codelayer/test/agent.test.ts index 0bac8a3..afe6996 100644 --- a/agents/codelayer/test/agent.test.ts +++ b/agents/codelayer/test/agent.test.ts @@ -111,6 +111,10 @@ function getAgentConfig(agent: object) { return agent as { model?: LanguageModel tools?: Record + forkConfig?: { + tools: Record + fork?: { tools: Record } + } system?: string | string[] hooks?: AgentConfig['hooks'] providerOptions?: Record @@ -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() @@ -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 () => { @@ -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() }) @@ -1213,7 +1221,6 @@ describe('createCodingSubagentTool', () => { const tool = getAgentConfig(agent).tools?.agent as Tool | 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 () => { @@ -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 () => { @@ -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) }) }) diff --git a/agents/codelayer/test/coding-subagent-tool.test.ts b/agents/codelayer/test/coding-subagent-tool.test.ts index 67cfa2b..a81ae2e 100644 --- a/agents/codelayer/test/coding-subagent-tool.test.ts +++ b/agents/codelayer/test/coding-subagent-tool.test.ts @@ -38,7 +38,7 @@ describe('createCodingSubagentTool', () => { system: 'test system prompt', }) const input = tool.input as any - const shape = input.shape as Record + const shape = input.shape as Record expect(Object.keys(shape)).toEqual([ 'description', @@ -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) }) @@ -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) }) @@ -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 () => { diff --git a/packages/agentlayer-core/src/agent.ts b/packages/agentlayer-core/src/agent.ts index d436681..3766508 100644 --- a/packages/agentlayer-core/src/agent.ts +++ b/packages/agentlayer-core/src/agent.ts @@ -48,6 +48,11 @@ export type ProviderOptions = Parameters[0]['providerOptions' export type ProviderOptionsFactory = (ctx: { runId: string; promptCacheKey?: string }) => ProviderOptions type StreamPart = TextStreamPart +export interface ForkAgentConfig { + tools: Record> + fork?: ForkAgentConfig +} + function isReasoningOnlyAssistantMessage(message: ModelMessage): boolean { if (message.role !== 'assistant' || !Array.isArray(message.content)) return false let hasReasoning = false @@ -95,6 +100,8 @@ export interface AgentConfig> = 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, @@ -297,6 +304,7 @@ export class Agent> = Record> = Record> = Record> = Record { 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()) diff --git a/packages/agentlayer-core/test/subagent-tool.test.ts b/packages/agentlayer-core/test/subagent-tool.test.ts index 105ab8a..25b8bd8 100644 --- a/packages/agentlayer-core/test/subagent-tool.test.ts +++ b/packages/agentlayer-core/test/subagent-tool.test.ts @@ -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')]), @@ -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.' diff --git a/packages/agentlayer-core/test/tool-interfaces.test.ts b/packages/agentlayer-core/test/tool-interfaces.test.ts index 297e84a..ca33e5e 100644 --- a/packages/agentlayer-core/test/tool-interfaces.test.ts +++ b/packages/agentlayer-core/test/tool-interfaces.test.ts @@ -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') - }) -}) diff --git a/packages/agentlayer-filesystem/test/coding-agent.test.ts b/packages/agentlayer-filesystem/test/coding-agent.test.ts index b70bc0b..a70aba1 100644 --- a/packages/agentlayer-filesystem/test/coding-agent.test.ts +++ b/packages/agentlayer-filesystem/test/coding-agent.test.ts @@ -14,7 +14,6 @@ import { createClaudeCodingAgentToolset, createCodexAgentFilesystemToolset, createCodexCodingAgentToolset, - createSkillToolFromRepoDirs, } from '../src' import { makeToolContext } from './mocks' @@ -58,25 +57,6 @@ async function withHomeEnvironment(home: string, run: () => Promise): Prom } } -async function withSkillResolutionFixture(run: (nestedCwd: string) => Promise): Promise { - return withTemporaryDirectory('agentlayer-skill-repo-', async (repoDir) => - withTemporaryDirectory('agentlayer-other-cwd-', async (unrelatedDir) => { - await initGitRepo(repoDir) - await mkdir(join(repoDir, '.claude', 'skills'), { recursive: true }) - await writeFile(join(repoDir, '.claude', 'skills', 'plan.md'), '# Plan\n\nDo the plan.') - const nestedCwd = join(repoDir, 'packages', 'app') - await mkdir(nestedCwd, { recursive: true }) - const originalCwd = process.cwd() - process.chdir(unrelatedDir) - try { - return await run(nestedCwd) - } finally { - process.chdir(originalCwd) - } - }), - ) -} - async function buildPublicPromptWithMixedInstructionSources() { const originalHome = process.env.HOME return withTemporaryDirectory('agentlayer-system-prompt-', async (fixtureRoot) => { @@ -147,15 +127,6 @@ async function withEmptyPromptFixture(run: (repoDir: string) => Promise): ) } -describe('createSkillToolFromRepoDirs', () => { - test('when the process cwd is unrelated, the provided cwd determines the repository whose skills are loaded', async () => { - await withSkillResolutionFixture(async (nestedCwd) => { - const skillTool = await createSkillToolFromRepoDirs({ cwd: nestedCwd }) - expect(skillTool.description).toContain('plan') - }) - }) -}) - describe('createAgentSystemPrompt', () => { test('the public prompt builder derives HOME from the environment and renders user, root, then cwd instructions', async () => { const result = await buildPublicPromptWithMixedInstructionSources() diff --git a/packages/agentlayer-filesystem/test/glob-tool.test.ts b/packages/agentlayer-filesystem/test/glob-tool.test.ts index 7b04388..8f4b9c1 100644 --- a/packages/agentlayer-filesystem/test/glob-tool.test.ts +++ b/packages/agentlayer-filesystem/test/glob-tool.test.ts @@ -13,10 +13,6 @@ describe('GlobTool interface', () => { expect(GlobTool.name).toBe('glob') }) - test('has non-empty description', () => { - expect(GlobTool.description.length).toBeGreaterThan(0) - }) - test('define() returns a tool with name "glob"', () => { const tool = GlobTool.define(async () => []) expect(tool.name).toBe('glob') diff --git a/packages/agentlayer-filesystem/test/grep-tool.test.ts b/packages/agentlayer-filesystem/test/grep-tool.test.ts index 50af444..f0ab076 100644 --- a/packages/agentlayer-filesystem/test/grep-tool.test.ts +++ b/packages/agentlayer-filesystem/test/grep-tool.test.ts @@ -13,10 +13,6 @@ describe('GrepTool interface', () => { expect(GrepTool.name).toBe('grep') }) - test('has non-empty description', () => { - expect(GrepTool.description.length).toBeGreaterThan(0) - }) - test('define() returns a tool with name "grep"', () => { const tool = GrepTool.define(async () => []) expect(tool.name).toBe('grep') diff --git a/packages/agentlayer-filesystem/test/list-tool.test.ts b/packages/agentlayer-filesystem/test/list-tool.test.ts index 6101c4b..4830fd5 100644 --- a/packages/agentlayer-filesystem/test/list-tool.test.ts +++ b/packages/agentlayer-filesystem/test/list-tool.test.ts @@ -13,10 +13,6 @@ describe('ListTool interface', () => { expect(ListTool.name).toBe('list') }) - test('has non-empty description', () => { - expect(ListTool.description.length).toBeGreaterThan(0) - }) - test('define() returns a tool with name "list"', () => { const tool = ListTool.define(async () => []) expect(tool.name).toBe('list') diff --git a/packages/agentlayer-filesystem/test/read-tool.test.ts b/packages/agentlayer-filesystem/test/read-tool.test.ts index d7ff42b..59e1bf8 100644 --- a/packages/agentlayer-filesystem/test/read-tool.test.ts +++ b/packages/agentlayer-filesystem/test/read-tool.test.ts @@ -23,10 +23,6 @@ describe('ReadTool interface', () => { expect(ReadTool.name).toBe('read') }) - test('has non-empty description', () => { - expect(ReadTool.description.length).toBeGreaterThan(0) - }) - test('define() returns a tool with the correct name', () => { const tool = ReadTool.define(async () => 'content') expect(tool.name).toBe('read') diff --git a/packages/agentlayer-filesystem/test/skill.test.ts b/packages/agentlayer-filesystem/test/skill.test.ts index 130535a..8f59ce2 100644 --- a/packages/agentlayer-filesystem/test/skill.test.ts +++ b/packages/agentlayer-filesystem/test/skill.test.ts @@ -136,20 +136,6 @@ describe('createSkillTool', () => { }) describe('createSkillToolFromDirs', () => { - test('reads flat .md skills from a directory', async () => { - const dir = await mkdtemp(join(tmpdir(), 'skill-test-')) - try { - await writeFile(join(dir, 'my-skill.md'), '# My Skill\n\nThis is my skill content.') - await writeFile(join(dir, 'another.md'), '# Another\n\nAnother skill.') - - const skillTool = await createSkillToolFromDirs({ dirs: dir }) - expect(skillTool.description).toContain('my-skill') - expect(skillTool.description).toContain('another') - } finally { - await rm(dir, { recursive: true }) - } - }) - test('SKILL.md convention sets baseDir to the skill directory', async () => { const dir = await mkdtemp(join(tmpdir(), 'skill-test-')) try { @@ -169,15 +155,4 @@ describe('createSkillToolFromDirs', () => { await rm(dir, { recursive: true }) } }) - - test('namespace prefixes loaded skill names', async () => { - const dir = await mkdtemp(join(tmpdir(), 'skill-test-')) - try { - await writeFile(join(dir, 'plan.md'), '# Plan\n\nPlan instructions.') - const skillTool = await createSkillToolFromDirs({ dirs: [{ path: dir, namespace: 'rpi' }] }) - expect(skillTool.description).toContain('rpi:plan') - } finally { - await rm(dir, { recursive: true }) - } - }) }) diff --git a/packages/agentlayer-provider-openai-codex/test/codex-ws-adapter.test.ts b/packages/agentlayer-provider-openai-codex/test/codex-ws-adapter.test.ts index 22077a0..1bee7b7 100644 --- a/packages/agentlayer-provider-openai-codex/test/codex-ws-adapter.test.ts +++ b/packages/agentlayer-provider-openai-codex/test/codex-ws-adapter.test.ts @@ -441,14 +441,8 @@ describe('convertTools', () => { expect(result).toHaveLength(1) expect(result[0]!.name).toBe('subagent') - expect(result[0]!.description).toBe('Dispatch a subagent') expect(result[0]!.inputSchema.required).toEqual(['prompt']) expect(result[0]!.inputSchema.additionalProperties).toBe(false) - const props = result[0]!.inputSchema.properties as Record> - expect(props.prompt!.description).toBe('Task or follow-up for the subagent.') - expect(props.agent_id!.description).toBe('Continue an existing subagent.') - expect(props.fork_turns!.description).toBe('Conversation to inherit.') - expect(props.subagent_type!.description).toBe('Start a registered specialist.') }) test('returns empty array for undefined tools', () => { @@ -469,21 +463,6 @@ describe('convertTools', () => { expect(result).toHaveLength(0) }) - - test('handles tools without description', () => { - const tools: LanguageModelV3CallOptions['tools'] = [ - { - type: 'function', - name: 'noop', - inputSchema: { type: 'object', properties: {} }, - }, - ] - - const result = convertTools(tools) - - expect(result).toHaveLength(1) - expect(result[0]!.description).toBe('') - }) }) // ---------------------------------------------------------------------------