From 82d1a6d64900de654b1036478609f4b0223eed72 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Mon, 28 Sep 2026 19:49:13 +0300 Subject: [PATCH 1/6] LOU-D1: Define DelegateAgentOptions + core createDelegateTool() Adds src/execution/DelegationTool.ts implementing createDelegateTool(), which wraps a child AgentConfig as a ToolDescriptor (AI SDK tool() wrapped per the ToolDescriptor{tool, needsApproval} shape used by built-in tools) so a parent agent can delegate a task to it via AgentExecutor.execute(). By default the child sees only the delegated task as a fresh user message, with no parent history leaking in. Co-Authored-By: Claude Sonnet 5 --- src/execution/DelegationTool.test.ts | 73 ++++++++++++++++++++++++++++ src/execution/DelegationTool.ts | 70 ++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/execution/DelegationTool.test.ts create mode 100644 src/execution/DelegationTool.ts diff --git a/src/execution/DelegationTool.test.ts b/src/execution/DelegationTool.test.ts new file mode 100644 index 000000000..ff34a7744 --- /dev/null +++ b/src/execution/DelegationTool.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createDelegateTool } from './DelegationTool'; +import { AgentType } from '../types'; +import type { LLMProvider, GenerateResult } from '../providers'; + +function makeGenerateResult(text: string): GenerateResult { + return { + text, + finishReason: 'stop', + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + }; +} + +function makeMockProvider(generate: LLMProvider['generate']): LLMProvider { + return { + name: 'mock', + generate, + stream: vi.fn() as any, + supportsTools: () => true, + supportsStreaming: () => true, + getModels: async () => [], + }; +} + +describe('createDelegateTool', () => { + it('invokes the child agent with only the delegated task as input', async () => { + const generate = vi.fn().mockResolvedValue(makeGenerateResult('child response')); + const provider = makeMockProvider(generate); + + const childAgent = { + name: 'Child Agent', + agentType: AgentType.SmartAssistant, + prompt: 'You are a helpful child agent', + }; + + const delegateTool = createDelegateTool({ + agent: childAgent, + provider, + }); + + const result = await delegateTool.tool.execute!({ task: 'Do the thing' }, {} as any); + + expect(generate).toHaveBeenCalledTimes(1); + const callArgs = generate.mock.calls[0][0]; + + // messages should contain the system prompt (from agent.prompt) plus + // exactly one user message with the delegated task - no unrelated + // parent conversation history leaking in. + const userMessages = callArgs.messages.filter((m: any) => m.role === 'user'); + expect(userMessages).toEqual([{ role: 'user', content: 'Do the thing' }]); + + expect(result).toEqual({ + text: 'child response', + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + }); + }); + + it('works with an agent that has no system prompt', async () => { + const generate = vi.fn().mockResolvedValue(makeGenerateResult('ok')); + const provider = makeMockProvider(generate); + + const childAgent = { + name: 'Bare Agent', + agentType: AgentType.SmartAssistant, + }; + + const delegateTool = createDelegateTool({ agent: childAgent, provider }); + await delegateTool.tool.execute!({ task: 'task 1' }, {} as any); + + const callArgs = generate.mock.calls[0][0]; + expect(callArgs.messages).toEqual([{ role: 'user', content: 'task 1' }]); + }); +}); diff --git a/src/execution/DelegationTool.ts b/src/execution/DelegationTool.ts new file mode 100644 index 000000000..d8828ab78 --- /dev/null +++ b/src/execution/DelegationTool.ts @@ -0,0 +1,70 @@ +/** + * Delegation Tool + * Wraps a child agent as a ToolDescriptor so a parent agent can delegate + * a subtask to it via ordinary tool-calling. + */ + +import { z } from 'zod'; +import { tool } from 'ai'; +import { AgentExecutor } from './AgentExecutor'; +import { LLMProvider } from '../providers'; +import { AgentConfig, ToolDescriptor } from '../types'; +import { ToolRegistry } from '../tools'; + +/** + * Options for creating a delegate-agent tool. + */ +export interface DelegateAgentOptions { + agent: AgentConfig; + provider: LLMProvider; + toolRegistry?: ToolRegistry; + /** + * 'none' (default): the child agent only sees the delegated task as a + * fresh user message - no shared history with the parent. + */ + contextMode?: 'none' | 'full-history'; + maxSteps?: number; +} + +/** + * Result of a delegated task execution. + */ +export interface DelegateAgentResult { + text: string; + usage: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; +} + +/** + * Create a ToolDescriptor that lets an agent delegate a task to a child + * agent, running the child through AgentExecutor.execute() and returning + * its final text/usage. + */ +export function createDelegateTool(opts: DelegateAgentOptions): ToolDescriptor { + return { + displayName: `Delegate to ${opts.agent.name}`, + tool: tool({ + description: `Delegates a task to the "${opts.agent.name}" agent and returns its response.`, + parameters: z.object({ + task: z.string().describe('The task/instructions to delegate to the child agent'), + }), + execute: async ({ task }: { task: string }): Promise => { + const result = await AgentExecutor.execute({ + agent: opts.agent, + input: [{ role: 'user', content: task }], + provider: opts.provider, + toolRegistry: opts.toolRegistry, + maxSteps: opts.maxSteps, + }); + + return { + text: result.text, + usage: result.usage, + }; + }, + }), + }; +} From 86fb5956ee55b68ac5c43d71cf030f3d2da84254 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Mon, 28 Sep 2026 19:53:49 +0300 Subject: [PATCH 2/6] LOU-D2: Add contextMode full-history + maxDepth guard Adds DelegationDepthExceededError and a maxDepth guard (default 3) to createDelegateTool(), plus contextMode:'full-history' support that prepends a `context` message array ahead of the delegated task. Depth propagation design: depth cannot be a closure-local counter on a single createDelegateTool() call, since an A -> B -> A chain involves two distinct createDelegateTool() instances (A's and B's), each with its own closure - a local counter on either would reset to 0 the moment control loops back through it. Instead a single module-scoped AsyncLocalStorage holds the ambient depth for whatever async call chain is executing; every delegate tool's execute() reads the ambient depth, checks it against its own maxDepth, and re-enters AgentExecutor.execute() for the child agent inside delegationDepthStorage.run(depth + 1, ...). Any further delegate tool reachable from the child agent (including one that loops back to an ancestor agent) observes the incremented depth automatically via Node's native async-context propagation, with no explicit parameter-threading through tool-call arguments (which would also be spoofable by the calling LLM). Also documents a related, pre-existing AgentExecutor behavior worth noting: AgentExecutor.executeToolCall catches every tool execute() error and converts it into a {error} tool-result message rather than rethrowing, so a DelegationDepthExceededError thrown several hops deep in a fully LLM-driven chain is swallowed by the nearest enclosing AgentExecutor.execute() rather than surfacing as a rejection at the top of the chain - the guard still stops the recursion either way (bounded by each level's own maxSteps), it just doesn't always propagate as a raw exception through multiple AgentExecutor layers. Tests exercise the guard's own depth-tracking logic directly. Co-Authored-By: Claude Sonnet 5 --- src/execution/DelegationTool.test.ts | 181 ++++++++++++++++++++++++++- src/execution/DelegationTool.ts | 105 ++++++++++++++-- 2 files changed, 271 insertions(+), 15 deletions(-) diff --git a/src/execution/DelegationTool.test.ts b/src/execution/DelegationTool.test.ts index ff34a7744..f509f9b40 100644 --- a/src/execution/DelegationTool.test.ts +++ b/src/execution/DelegationTool.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, vi } from 'vitest'; -import { createDelegateTool } from './DelegationTool'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createDelegateTool, DelegationDepthExceededError } from './DelegationTool'; +import { AgentExecutor } from './AgentExecutor'; import { AgentType } from '../types'; import type { LLMProvider, GenerateResult } from '../providers'; @@ -70,4 +71,180 @@ describe('createDelegateTool', () => { const callArgs = generate.mock.calls[0][0]; expect(callArgs.messages).toEqual([{ role: 'user', content: 'task 1' }]); }); + + describe('contextMode: full-history', () => { + it('passes context history then the task message, in order', async () => { + const generate = vi.fn().mockResolvedValue(makeGenerateResult('done')); + const provider = makeMockProvider(generate); + + const agent = { name: 'Contextual Agent', agentType: AgentType.SmartAssistant }; + const delegateTool = createDelegateTool({ agent, provider, contextMode: 'full-history' }); + + const context = [ + { role: 'user' as const, content: 'earlier question' }, + { role: 'assistant' as const, content: 'earlier answer' }, + ]; + + await delegateTool.tool.execute!({ task: 'follow-up task', context }, {} as any); + + const callArgs = generate.mock.calls[0][0]; + expect(callArgs.messages).toEqual([...context, { role: 'user', content: 'follow-up task' }]); + }); + + it('falls back to task-only input when contextMode is the default "none"', async () => { + const generate = vi.fn().mockResolvedValue(makeGenerateResult('done')); + const provider = makeMockProvider(generate); + + const agent = { name: 'Contextual Agent', agentType: AgentType.SmartAssistant }; + const delegateTool = createDelegateTool({ agent, provider }); + + const context = [{ role: 'user' as const, content: 'earlier question' }]; + await delegateTool.tool.execute!({ task: 'follow-up task', context }, {} as any); + + const callArgs = generate.mock.calls[0][0]; + expect(callArgs.messages).toEqual([{ role: 'user', content: 'follow-up task' }]); + }); + }); + + describe('maxDepth guard', () => { + // NOTE on how these tests are structured: AgentExecutor.executeToolCall + // deliberately catches every error thrown by a tool's execute() and + // converts it into a `{ error: message }` tool-result message (so the + // calling LLM can react to a failed tool call conversationally) rather + // than rethrowing it (see AgentExecutor.ts, established in LOU-C). That + // means a DelegationDepthExceededError thrown several hops deep inside + // a *real*, fully LLM-driven A -> B -> A chain would be swallowed + // by the nearest enclosing AgentExecutor.execute() and would never + // reach the outermost caller as a rejected promise - even though the + // depth guard has still done its job and stopped the recursion (the + // chain terminates gracefully, bounded by each level's own maxSteps, + // rather than growing the call stack unboundedly). + // + // To directly unit-test the depth-tracking/guard logic itself (which is + // this ticket's actual subject) independent of that swallowing + // behavior, these tests stub out AgentExecutor.execute so that a + // "child agent" immediately re-delegates by calling the *other* delegate + // tool's execute() function directly, in the same way AgentExecutor's + // real dispatch loop would - but without the try/catch that would + // otherwise absorb the thrown error. This still exercises the real + // AsyncLocalStorage-based depth propagation in DelegationTool.ts. + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('propagates depth across an A -> B -> A chain and throws once maxDepth is exceeded', async () => { + const provider = makeMockProvider(vi.fn()); + const agentA = { name: 'Agent A', agentType: AgentType.SmartAssistant }; + const agentB = { name: 'Agent B', agentType: AgentType.SmartAssistant }; + + const delegateToB = createDelegateTool({ agent: agentB, provider, maxDepth: 2 }); + const delegateToA = createDelegateTool({ agent: agentA, provider, maxDepth: 2 }); + + vi.spyOn(AgentExecutor, 'execute').mockImplementation(async (options) => { + if (options.agent === agentA) { + const r = await delegateToB.tool.execute!({ task: 'to B' }, {} as any); + return { text: r.text, messages: [], toolCalls: [], usage: r.usage, finishReason: 'stop', steps: 1 }; + } + if (options.agent === agentB) { + const r = await delegateToA.tool.execute!({ task: 'to A' }, {} as any); + return { text: r.text, messages: [], toolCalls: [], usage: r.usage, finishReason: 'stop', steps: 1 }; + } + throw new Error('unexpected agent in test stub'); + }); + + // A delegates to B (depth 0 -> 1), B delegates back to A (depth 1 -> + // 2), A tries to delegate to B again but depth (2) >= maxDepth (2). + await expect(delegateToB.tool.execute!({ task: 'start' }, {} as any)).rejects.toThrow( + DelegationDepthExceededError + ); + }); + + it('succeeds when a delegation chain is exactly maxDepth hops long', async () => { + const provider = makeMockProvider(vi.fn()); + const agent = { name: 'Recursive Agent', agentType: AgentType.SmartAssistant }; + const maxDepth = 2; + const totalHops = maxDepth; + + const delegateTool = createDelegateTool({ agent, provider, maxDepth }); + + let callCount = 0; + vi.spyOn(AgentExecutor, 'execute').mockImplementation(async () => { + callCount++; + if (callCount < totalHops) { + const r = await delegateTool.tool.execute!({ task: `hop ${callCount}` }, {} as any); + return { text: r.text, messages: [], toolCalls: [], usage: r.usage, finishReason: 'stop', steps: 1 }; + } + return { + text: 'done', + messages: [], + toolCalls: [], + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + finishReason: 'stop', + steps: 1, + }; + }); + + await expect(delegateTool.tool.execute!({ task: 'start' }, {} as any)).resolves.toEqual( + expect.objectContaining({ text: 'done' }) + ); + }); + + it('fails when a delegation chain goes one hop beyond maxDepth', async () => { + const provider = makeMockProvider(vi.fn()); + const agent = { name: 'Recursive Agent', agentType: AgentType.SmartAssistant }; + const maxDepth = 2; + const totalHops = maxDepth + 1; + + const delegateTool = createDelegateTool({ agent, provider, maxDepth }); + + let callCount = 0; + vi.spyOn(AgentExecutor, 'execute').mockImplementation(async () => { + callCount++; + if (callCount < totalHops) { + const r = await delegateTool.tool.execute!({ task: `hop ${callCount}` }, {} as any); + return { text: r.text, messages: [], toolCalls: [], usage: r.usage, finishReason: 'stop', steps: 1 }; + } + return { + text: 'done', + messages: [], + toolCalls: [], + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + finishReason: 'stop', + steps: 1, + }; + }); + + await expect(delegateTool.tool.execute!({ task: 'start' }, {} as any)).rejects.toThrow( + DelegationDepthExceededError + ); + }); + + it('defaults maxDepth to 3 when omitted', async () => { + const provider = makeMockProvider(vi.fn()); + const agent = { name: 'Recursive Agent', agentType: AgentType.SmartAssistant }; + const delegateTool = createDelegateTool({ agent, provider }); + + let callCount = 0; + const totalHops = 5; // well beyond the default maxDepth of 3 + vi.spyOn(AgentExecutor, 'execute').mockImplementation(async () => { + callCount++; + if (callCount < totalHops) { + const r = await delegateTool.tool.execute!({ task: `hop ${callCount}` }, {} as any); + return { text: r.text, messages: [], toolCalls: [], usage: r.usage, finishReason: 'stop', steps: 1 }; + } + return { + text: 'done', + messages: [], + toolCalls: [], + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + finishReason: 'stop', + steps: 1, + }; + }); + + await expect(delegateTool.tool.execute!({ task: 'start' }, {} as any)).rejects.toThrow( + DelegationDepthExceededError + ); + }); + }); }); diff --git a/src/execution/DelegationTool.ts b/src/execution/DelegationTool.ts index d8828ab78..e72cfcfb0 100644 --- a/src/execution/DelegationTool.ts +++ b/src/execution/DelegationTool.ts @@ -6,11 +6,50 @@ import { z } from 'zod'; import { tool } from 'ai'; +import { AsyncLocalStorage } from 'node:async_hooks'; import { AgentExecutor } from './AgentExecutor'; -import { LLMProvider } from '../providers'; +import { LLMProvider, Message } from '../providers'; import { AgentConfig, ToolDescriptor } from '../types'; import { ToolRegistry } from '../tools'; +/** + * Thrown when a delegation chain exceeds the configured maxDepth without + * ever bottoming out (e.g. an A -> B -> A cycle). Prevents infinite + * recursion / stack overflow from a runaway delegate chain. + */ +export class DelegationDepthExceededError extends Error { + constructor(maxDepth: number) { + super(`Delegation depth exceeded maximum of ${maxDepth}`); + this.name = 'DelegationDepthExceededError'; + } +} + +/** + * Tracks how many delegate-tool hops deep the current async call chain is. + * + * Depth cannot be tracked as a closure variable local to a single + * createDelegateTool() call: in an A -> B -> A chain, agent A's delegate + * tool and agent B's delegate tool are two *separate* createDelegateTool() + * instances (each with its own closure), so a local counter on either one + * would reset to 0 the moment control re-enters "A" via B's tool call and + * never observe that this is actually the second hop through A. + * + * Instead we use a single module-scoped AsyncLocalStorage that holds the + * current depth for whatever async call chain is executing. Every + * createDelegateTool() instance reads/writes the *same* store, so depth is + * shared across the whole chain regardless of how many distinct delegate + * tools (and therefore closures) participate: each execute() reads the + * ambient depth, checks it against its own maxDepth, then re-enters + * AgentExecutor.execute() for the child agent inside + * delegationDepthStorage.run(depth + 1, ...) - so any delegate tool the + * child agent goes on to call (including a tool that loops back to an + * ancestor agent) observes the incremented depth automatically, via + * Node's native async-context propagation, without any explicit + * parameter-threading through tool call arguments (which would also be + * spoofable by the calling LLM). + */ +const delegationDepthStorage = new AsyncLocalStorage(); + /** * Options for creating a delegate-agent tool. */ @@ -21,9 +60,17 @@ export interface DelegateAgentOptions { /** * 'none' (default): the child agent only sees the delegated task as a * fresh user message - no shared history with the parent. + * 'full-history': the child agent additionally sees whatever + * conversation history is passed via the tool's `context` parameter, + * with the task appended as the final user message. */ contextMode?: 'none' | 'full-history'; maxSteps?: number; + /** + * Maximum number of hops allowed in a delegation chain before a + * DelegationDepthExceededError is thrown. Defaults to 3. + */ + maxDepth?: number; } /** @@ -44,26 +91,58 @@ export interface DelegateAgentResult { * its final text/usage. */ export function createDelegateTool(opts: DelegateAgentOptions): ToolDescriptor { + const maxDepth = opts.maxDepth ?? 3; + return { displayName: `Delegate to ${opts.agent.name}`, tool: tool({ description: `Delegates a task to the "${opts.agent.name}" agent and returns its response.`, parameters: z.object({ task: z.string().describe('The task/instructions to delegate to the child agent'), + context: z + .array( + z.object({ + role: z.enum(['system', 'user', 'assistant', 'tool']), + content: z.string(), + }) + ) + .optional() + .describe( + 'Optional prior conversation history to share with the child agent (only used when contextMode is "full-history")' + ), }), - execute: async ({ task }: { task: string }): Promise => { - const result = await AgentExecutor.execute({ - agent: opts.agent, - input: [{ role: 'user', content: task }], - provider: opts.provider, - toolRegistry: opts.toolRegistry, - maxSteps: opts.maxSteps, - }); + execute: async ({ + task, + context, + }: { + task: string; + context?: Message[]; + }): Promise => { + const currentDepth = delegationDepthStorage.getStore() ?? 0; + + if (currentDepth >= maxDepth) { + throw new DelegationDepthExceededError(maxDepth); + } - return { - text: result.text, - usage: result.usage, - }; + const childInput: Message[] = + opts.contextMode === 'full-history' && context + ? [...context, { role: 'user', content: task }] + : [{ role: 'user', content: task }]; + + return delegationDepthStorage.run(currentDepth + 1, async () => { + const result = await AgentExecutor.execute({ + agent: opts.agent, + input: childInput, + provider: opts.provider, + toolRegistry: opts.toolRegistry, + maxSteps: opts.maxSteps, + }); + + return { + text: result.text, + usage: result.usage, + }; + }); }, }), }; From b22c478d65c3314ce2a889f0394dfd5a59d54a5e Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Mon, 28 Sep 2026 19:56:30 +0300 Subject: [PATCH 3/6] LOU-D3: Registration example + export Re-exports createDelegateTool, DelegateAgentOptions, and DelegationDepthExceededError from src/execution/index.ts (picked up by src/index.ts's existing `export * from './execution'`), and adds a Multi-agent Delegation example to README.md showing registry.register('delegate_billing_agent', createDelegateTool({...})) against the real ToolRegistry.register(name, descriptor) signature. Verified `npm run build` exits 0 and, with the optional @ai-sdk/openai / ollama-ai-provider peer deps installed, a scratch script importing createDelegateTool/DelegationDepthExceededError/ AgentExecutor from dist/index.mjs resolves without a module-not-found error. Co-Authored-By: Claude Sonnet 5 --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++ src/execution/index.ts | 1 + 2 files changed, 55 insertions(+) diff --git a/README.md b/README.md index 84ce65fa6..070627642 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,60 @@ const ollama = LLMProviderRegistry.create('ollama', { ## Advanced Features +### Multi-agent Delegation + +Wrap a child `AgentConfig` as a tool so a parent agent can delegate a task +to it, running the child through `AgentExecutor.execute()` under the hood: + +```typescript +import { + AgentExecutor, + createDelegateTool, + ToolRegistry, + AgentType, +} from '@loushy/build-ai-agent'; + +const billingAgent = { + name: 'Billing Agent', + agentType: AgentType.SmartAssistant, + prompt: 'You answer billing questions and look up invoices.', +}; + +const registry = new ToolRegistry(); +registry.register( + 'delegate_billing_agent', + createDelegateTool({ + agent: billingAgent, + provider, // an LLMProvider instance + // contextMode: 'full-history' shares the `context` array the tool is + // called with (in addition to the delegated task); 'none' (default) + // gives the child agent only the task as a fresh message. + contextMode: 'none', + maxSteps: 10, + // maxDepth (default 3) bounds how many hops a delegation chain may + // take (e.g. agent A delegates to B, which delegates back to A, ...) + // before a DelegationDepthExceededError is thrown. + maxDepth: 3, + }) +); + +const supportAgent = { + name: 'Support Agent', + agentType: AgentType.SmartAssistant, + prompt: 'You help customers. Delegate billing questions to the billing agent.', + tools: { + delegate_billing_agent: { tool: 'delegate_billing_agent' }, + }, +}; + +const result = await AgentExecutor.execute({ + agent: supportAgent, + input: 'Why was I charged twice this month?', + provider, + toolRegistry: registry, +}); +``` + ### Security & Encryption ```typescript diff --git a/src/execution/index.ts b/src/execution/index.ts index 398fd7ffb..dd0d30cfc 100644 --- a/src/execution/index.ts +++ b/src/execution/index.ts @@ -4,6 +4,7 @@ */ export * from './AgentExecutor'; +export * from './DelegationTool'; export * from './MemoryManager'; export * from './ContextBuilder'; export * from './errors'; From aac7e76f03352aed7e2e8faf5f7d37fcb0d434bb Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Mon, 28 Sep 2026 19:57:13 +0300 Subject: [PATCH 4/6] LOU-D4: Verify FlowExecutor compatibility Adds a test mirroring the existing "should execute tool" toolCall-step test, but pointing at a delegate tool built with createDelegateTool() instead of a built-in-style tool. Asserts the emitted event-type sequence for the delegate-tool flow matches the built-in-tool flow exactly, and that the result is a plain object surfaced the same way. No compatibility gap was found: FlowExecutor's toolCall step dispatches via `toolDesc.tool.execute(args, {})`, and createDelegateTool() produces exactly that shape, so delegate tools work as drop-in toolCall targets with no new FlowStep type or special-casing required. Co-Authored-By: Claude Sonnet 5 --- src/flows/FlowExecutor.test.ts | 73 ++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/flows/FlowExecutor.test.ts b/src/flows/FlowExecutor.test.ts index 4f9a279df..00e4e0493 100644 --- a/src/flows/FlowExecutor.test.ts +++ b/src/flows/FlowExecutor.test.ts @@ -475,6 +475,79 @@ describe('FlowExecutor', () => { }); }); + describe('LOU-D4: delegate tool compatibility', () => { + it('produces the same event sequence as a built-in tool step', async () => { + // Template: this mirrors "should execute tool" above (a single + // toolCall step against a registered tool) exactly, except the tool + // name points at a delegate tool created by createDelegateTool() + // instead of a hand-rolled built-in-style tool. If delegate tools are + // truly drop-in compatible with FlowExecutor's toolCall step, the two + // flows should produce an identical sequence of event *types* (only + // the event `data` payloads legitimately differ, since the tools do + // different things). + const { createDelegateTool } = await import('../execution/DelegationTool'); + const { AgentType } = await import('../types'); + + const childProvider = new MockLLMProvider({ + name: 'mock-child', + responses: ['delegated response'], + }); + + const childAgent = { + name: 'Delegate Test Agent', + agentType: AgentType.SmartAssistant, + prompt: 'You are a test child agent', + }; + + toolRegistry.register( + 'delegate_test_agent', + createDelegateTool({ agent: childAgent, provider: childProvider }) + ); + + const builtinFlow: AgentFlow = { + code: 'builtin-flow', + name: 'Builtin Flow', + flow: { + type: 'toolCall', + tool: 'testTool', + arguments: { value: 'test' }, + }, + }; + + const delegateFlow: AgentFlow = { + code: 'delegate-flow', + name: 'Delegate Flow', + flow: { + type: 'toolCall', + tool: 'delegate_test_agent', + arguments: { task: 'test' }, + }, + }; + + const builtinEvents: any[] = []; + const builtinResult = await FlowExecutor.execute(builtinFlow, context, (event) => + builtinEvents.push(event) + ); + + const delegateEvents: any[] = []; + const delegateResult = await FlowExecutor.execute(delegateFlow, context, (event) => + delegateEvents.push(event) + ); + + expect(delegateResult.success).toBe(true); + expect(builtinResult.success).toBe(true); + expect(delegateEvents.map((e) => e.type)).toEqual(builtinEvents.map((e) => e.type)); + + // Result shape: the delegate tool's own return value (text/usage) is + // naturally different from the built-in test tool's return value, + // but it is still a plain result object surfaced the same way a + // built-in tool's result would be - no new FlowStep type or special + // casing was needed in FlowExecutor for delegate tools to work. + expect(delegateResult.output).toHaveProperty('text'); + expect(delegateResult.output).toHaveProperty('usage'); + }); + }); + describe('Events', () => { it('should emit execution events', async () => { const events: any[] = []; From 0115f7acef094f2845436832c57aea0208f2c637 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Mon, 28 Sep 2026 20:02:23 +0300 Subject: [PATCH 5/6] LOU-D fix: propagate DelegationDepthExceededError instead of silently absorbing it into tool-loop retries AgentExecutor.executeToolCall's catch-all was converting every thrown tool error, including DelegationDepthExceededError from DelegationTool.ts's maxDepth guard, into a {error} tool-result message fed back to the LLM. In a pathological A -> B -> A delegation loop, this let the depth guard fire correctly a few hops down but only handed the LLM more conversational fodder that triggered another delegation attempt, causing each nested level to separately exhaust its own maxSteps budget (O(maxSteps^maxDepth) LLM calls, confirmed by QA: 155 calls at maxDepth=2/maxSteps=5, 780 at maxDepth=3/maxSteps=5) with zero signal to the top-level caller that anything went wrong. Add a PropagatingToolError marker class in AgentExecutor.ts (avoiding a circular import back from DelegationTool.ts, which already imports AgentExecutor) and have DelegationDepthExceededError extend it. executeToolCall now rethrows errors that are instanceof PropagatingToolError instead of swallowing them, so the depth-exceeded error propagates all the way up through every nested execute() call as a rejected promise. Ordinary tool errors are unaffected and still become {error} tool-result messages. Adds a regression test in DelegationTool.test.ts driving a real A <-> B delegation loop through AgentExecutor.execute() + createDelegateTool() (not the original stubbed-execute unit test, which couldn't catch this) confirming the top-level execute() call now rejects and total LLM calls stay bounded under 20 instead of hundreds. Co-Authored-By: Claude Sonnet 5 --- src/execution/AgentExecutor.ts | 34 ++++++++++ src/execution/DelegationTool.test.ts | 92 ++++++++++++++++++++++++++++ src/execution/DelegationTool.ts | 4 +- 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/execution/AgentExecutor.ts b/src/execution/AgentExecutor.ts index af331d71a..e40b6c89a 100644 --- a/src/execution/AgentExecutor.ts +++ b/src/execution/AgentExecutor.ts @@ -10,6 +10,29 @@ import { ToolRegistry } from '../tools'; import { ApprovalStore, ExecutionSnapshot, PendingApproval } from './ApprovalGate'; import { Checkpoint, CheckpointStore } from './checkpoint'; +/** + * Base class for tool errors that must NOT be swallowed by + * executeToolCall()'s catch-all and converted into a conversational + * `{error: ...}` tool-result message fed back to the LLM. Instead they + * should propagate up out of execute() as a rejected promise, terminating + * the run and giving the caller (which may itself be a parent delegate + * tool's execute(), see DelegationTool.ts) an unambiguous signal. + * + * DelegationTool.ts's DelegationDepthExceededError extends this so that a + * runaway delegation cycle (A -> B -> A -> ...) is stopped dead the moment + * any one level's maxDepth guard fires, rather than having that error + * re-enter the conversation as tool output that prompts the LLM to retry + * the delegation - which is what let the original bug grow unbounded + * (O(maxSteps^maxDepth) LLM calls) instead of failing fast. + * + * This lives here (not in DelegationTool.ts) because DelegationTool.ts + * already imports AgentExecutor from this file; having AgentExecutor.ts + * import back from DelegationTool.ts would be a circular import. Defining + * the shared marker in this lower-level file lets both directions work + * without a cycle. + */ +export class PropagatingToolError extends Error {} + /** * Execution event types */ @@ -467,6 +490,17 @@ export class AgentExecutor { result, }; } catch (error) { + // Errors that mark themselves as `PropagatingToolError` (e.g. + // DelegationDepthExceededError) must NOT be converted into a + // conversational {error} tool-result - that would hand the LLM + // exactly the kind of "your tool call failed, try again" signal + // that triggers another delegation attempt, defeating the whole + // point of the depth guard. Rethrow so it propagates out of + // execute() as a rejected promise instead. + if (error instanceof PropagatingToolError) { + throw error; + } + return { toolCallId: toolCall.id, toolName: toolCall.function.name, diff --git a/src/execution/DelegationTool.test.ts b/src/execution/DelegationTool.test.ts index f509f9b40..dc974f68d 100644 --- a/src/execution/DelegationTool.test.ts +++ b/src/execution/DelegationTool.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { createDelegateTool, DelegationDepthExceededError } from './DelegationTool'; import { AgentExecutor } from './AgentExecutor'; import { AgentType } from '../types'; +import { ToolRegistry } from '../tools'; import type { LLMProvider, GenerateResult } from '../providers'; function makeGenerateResult(text: string): GenerateResult { @@ -246,5 +247,96 @@ describe('createDelegateTool', () => { DelegationDepthExceededError ); }); + + // LOU-D regression test: unlike the tests above (which stub out + // AgentExecutor.execute to unit-test depth tracking in isolation), this + // one drives a *real* A <-> B delegation loop through the real + // AgentExecutor.execute() + createDelegateTool() dispatch path, with + // mock LLM providers that unconditionally request delegation to the + // other agent, forever. This is QA's exact reproduction scenario: it + // caught a bug the stubbed tests above could not, because + // executeToolCall's catch-all was silently converting + // DelegationDepthExceededError into a `{error}` tool-result message fed + // back to the LLM, which triggered another delegation attempt instead + // of terminating the run - causing O(maxSteps^maxDepth) LLM calls + // instead of a bounded, fast failure. + it('bounds total LLM calls and rejects the top-level execute() when A and B loop forever delegating to each other', async () => { + const maxDepth = 2; + const maxSteps = 5; + + function makeDelegateToolCall(toolName: string, task: string): GenerateResult { + return { + text: '', + finishReason: 'tool_calls', + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + toolCalls: [ + { + id: `call-${Math.random().toString(36).slice(2)}`, + type: 'function', + function: { name: toolName, arguments: JSON.stringify({ task }) }, + }, + ], + }; + } + + // Always requests delegation to the other agent - never stops on its + // own. Exactly the "neither agent ever stops" pathological case from + // the bug report. + const generateA = vi.fn(async () => makeDelegateToolCall('delegate', 'go to B')); + const generateB = vi.fn(async () => makeDelegateToolCall('delegate', 'go to A')); + + const providerA = makeMockProvider(generateA); + const providerB = makeMockProvider(generateB); + + const agentA = { name: 'Agent A', agentType: AgentType.SmartAssistant, tools: { delegate: { tool: 'delegate' } } }; + const agentB = { name: 'Agent B', agentType: AgentType.SmartAssistant, tools: { delegate: { tool: 'delegate' } } }; + + const registryA = new ToolRegistry(); + const registryB = new ToolRegistry(); + + // A's "delegate" tool hands off to B (running through registryB / + // providerB), and B's "delegate" tool hands off back to A - a genuine + // A -> B -> A -> ... cycle, each side wired with real + // AgentExecutor.execute() underneath (createDelegateTool does not + // stub anything). + const delegateToBFromA = createDelegateTool({ + agent: agentB, + provider: providerB, + toolRegistry: registryB, + maxDepth, + maxSteps, + }); + const delegateToAFromB = createDelegateTool({ + agent: agentA, + provider: providerA, + toolRegistry: registryA, + maxDepth, + maxSteps, + }); + + registryA.register('delegate', delegateToBFromA); + registryB.register('delegate', delegateToAFromB); + + const execution = AgentExecutor.execute({ + agent: agentA, + input: 'start the loop', + provider: providerA, + toolRegistry: registryA, + maxSteps, + }); + + // (a) the top-level execute() call must reject with a clear + // depth-exceeded signal, not resolve with a normal-looking + // finishReason: 'tool-calls' result. + await expect(execution).rejects.toThrow(DelegationDepthExceededError); + + // (b) total LLM calls across the whole run must stay small/bounded - + // roughly proportional to maxDepth, not maxSteps^maxDepth. Before the + // fix, QA measured 155 calls for maxDepth=2/maxSteps=5 (and 780 for + // maxDepth=3/maxSteps=5); after the fix this should be a handful of + // calls (one per hop until the guard fires), well under 20. + const totalCalls = generateA.mock.calls.length + generateB.mock.calls.length; + expect(totalCalls).toBeLessThan(20); + }); }); }); diff --git a/src/execution/DelegationTool.ts b/src/execution/DelegationTool.ts index e72cfcfb0..ab7347385 100644 --- a/src/execution/DelegationTool.ts +++ b/src/execution/DelegationTool.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; import { tool } from 'ai'; import { AsyncLocalStorage } from 'node:async_hooks'; -import { AgentExecutor } from './AgentExecutor'; +import { AgentExecutor, PropagatingToolError } from './AgentExecutor'; import { LLMProvider, Message } from '../providers'; import { AgentConfig, ToolDescriptor } from '../types'; import { ToolRegistry } from '../tools'; @@ -17,7 +17,7 @@ import { ToolRegistry } from '../tools'; * ever bottoming out (e.g. an A -> B -> A cycle). Prevents infinite * recursion / stack overflow from a runaway delegate chain. */ -export class DelegationDepthExceededError extends Error { +export class DelegationDepthExceededError extends PropagatingToolError { constructor(maxDepth: number) { super(`Delegation depth exceeded maximum of ${maxDepth}`); this.name = 'DelegationDepthExceededError'; From ff3e8784ce622c67763234053cdbecdb9cbe4ec8 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Mon, 28 Sep 2026 20:05:45 +0300 Subject: [PATCH 6/6] LOU-D fix: apply PropagatingToolError special-case to resume.ts's tool-error handling resume.ts's catch block around the deferred tool's execute() call on the approval-resume path had its own generic tool-error catch/convert block that was never given the same PropagatingToolError special-case AgentExecutor.executeToolCall got in commit 0115f7a. A deferred tool that itself hits a delegation-depth limit (DelegationDepthExceededError) was being caught and converted into a {error} tool-result message fed back into the conversation - the exact swallow-and-silently-continue pattern the AgentExecutor fix exists to prevent, just at this other call site. Add the same `if (error instanceof PropagatingToolError) throw error;` check before the catch-and-convert fallback, so resumeAfterApproval's returned promise rejects instead of resolving with an error-shaped tool message. Update the stale code comment that claimed to mirror AgentExecutor's pre-fix behavior. Add a regression test confirming the promise rejects for a PropagatingToolError and confirming ordinary tool errors still resolve with an {error}-shaped message as before. Co-Authored-By: Claude Sonnet 5 --- src/execution/resume.test.ts | 54 +++++++++++++++++++++++++++++++++++- src/execution/resume.ts | 33 +++++++++++++++------- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/execution/resume.test.ts b/src/execution/resume.test.ts index 5b2fbbb62..219405db3 100644 --- a/src/execution/resume.test.ts +++ b/src/execution/resume.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { resumeAfterApproval } from './resume'; import { ApprovalStore, PendingApproval, ExecutionSnapshot } from './ApprovalGate'; -import { AgentExecutor } from './AgentExecutor'; +import { AgentExecutor, PropagatingToolError } from './AgentExecutor'; import { Checkpoint, CheckpointStore } from './checkpoint'; import { createMockProvider } from '../providers/mock'; import { ToolRegistry } from '../tools'; @@ -240,6 +240,58 @@ describe('Execution - resumeAfterApproval', () => { expect(parsed.error).toBe('payment gateway timeout'); }); + it('should reject (not resolve with an error-shaped tool message) when the deferred tool throws a PropagatingToolError on resume', async () => { + // Reproduces the LOU-D reviewer finding: resume.ts's own catch/convert + // block for the deferred tool's execute() call never got the same + // PropagatingToolError special-case that AgentExecutor.executeToolCall + // got in the AgentExecutor fix. A deferred tool that is itself a + // delegate tool hitting its delegation-depth limit throws a + // PropagatingToolError subclass (DelegationDepthExceededError); that + // must propagate out of resumeAfterApproval() as a rejected promise, + // not get swallowed into a {error} tool-result message that would let + // the LLM see a normal failure and retry the delegation. + const depthError = new PropagatingToolError('delegation depth exceeded'); + const execute = vi.fn().mockRejectedValue(depthError); + toolRegistry.register('delegate', { + displayName: 'Delegate', + tool: { description: 'Delegate to a sub-agent', parameters: {}, execute } as any, + needsApproval: true, + }); + + const agent = AgentBuilder.create() + .setType(AgentType.SmartAssistant) + .setName('Test Agent') + .addTool('delegate', { tool: 'delegate', options: {} }) + .build(); + + const provider = createMockProvider({ + name: 'mock', + responses: ['Delegating now', 'All done'], + }); + + const approvalStore = createInMemoryApprovalStore(); + + const paused = await AgentExecutor.execute({ + agent, + input: 'Please delegate now', + provider, + toolRegistry, + approvalStore, + }); + + expect(paused.finishReason).toBe('awaiting-approval'); + + // Before the fix, this would resolve with an {error}-shaped tool + // message (the exact swallow-and-silently-continue pattern the + // AgentExecutor fix exists to prevent). After the fix, it must reject + // with the same PropagatingToolError instance instead. + await expect( + resumeAfterApproval({ id: paused.approvalId!, approved: true }, approvalStore, toolRegistry, provider) + ).rejects.toBe(depthError); + + expect(execute).toHaveBeenCalledTimes(1); + }); + it('should continue step-count accounting from the pre-pause step count on resume', async () => { const execute = vi.fn().mockResolvedValue({ ok: true }); toolRegistry.register('chargeCard', { diff --git a/src/execution/resume.ts b/src/execution/resume.ts index cbf8191df..f324e426f 100644 --- a/src/execution/resume.ts +++ b/src/execution/resume.ts @@ -6,7 +6,7 @@ import { LLMProvider, Message } from '../providers'; import { ToolRegistry } from '../tools'; import { ApprovalDecision, ApprovalStore } from './ApprovalGate'; -import { AgentExecutor, ExecuteOptions, ExecutionResult } from './AgentExecutor'; +import { AgentExecutor, ExecuteOptions, ExecutionResult, PropagatingToolError } from './AgentExecutor'; import { CheckpointStore } from './checkpoint'; /** @@ -97,15 +97,28 @@ export async function resumeAfterApproval( toolName: pending.toolName, }); } catch (error) { - // Mirror AgentExecutor.executeToolCall's handling of a thrown tool - // error: turn it into a graceful tool-result message instead of - // letting it reject this promise. By this point the pending-approval - // record has already been deleted (ApprovalGate.resolve() is - // delete-on-read), so failing to catch here would mean the whole - // resume just fails with no retry path. Uses the same - // `(error as Error).message` extraction AgentExecutor's catch block - // uses, wrapped in the `{error}`-shaped payload this file's own - // rejection branch (below) already uses for non-approved decisions. + // Mirror AgentExecutor.executeToolCall's (post-fix) handling of a + // thrown tool error: errors that mark themselves as + // `PropagatingToolError` (e.g. DelegationDepthExceededError) must NOT + // be converted into a conversational {error} tool-result - that would + // hand the LLM exactly the kind of "your tool call failed, try again" + // signal that triggers another delegation attempt, defeating the + // whole point of the depth guard. Rethrow so it propagates out of + // this function as a rejected promise instead, exactly like + // AgentExecutor.executeToolCall does. + if (error instanceof PropagatingToolError) { + throw error; + } + + // Every other thrown tool error is turned into a graceful tool-result + // message instead of letting it reject this promise. By this point + // the pending-approval record has already been deleted + // (ApprovalGate.resolve() is delete-on-read), so failing to catch + // here would mean the whole resume just fails with no retry path. + // Uses the same `(error as Error).message` extraction AgentExecutor's + // catch block uses, wrapped in the `{error}`-shaped payload this + // file's own rejection branch (below) already uses for non-approved + // decisions. messages.push({ role: 'tool', content: JSON.stringify({ error: (error as Error).message }),