Repository navigation
LOU-D: Multi-agent delegation - #5
Merged
Merged
Conversation
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 <noreply@anthropic.com>
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<number> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…l-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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements all 4 tickets from LOU-D (Phase 1), adding an agent-to-agent delegation primitive:
createDelegateTool(opts: DelegateAgentOptions)wraps a childAgentConfigas aToolDescriptor, invoking the child's ownAgentExecutor.execute()with a clean context (contextMode: 'none', default) and returning itstext/usage.contextMode: 'full-history'(opt-in, prepends passed context to the child's input) and amaxDepthguard against delegation loops (A delegates to B which delegates back to A). Depth is tracked via a module-scopedAsyncLocalStorage<number>so it propagates correctly across an A→B→A chain via Node's native async-context — not spoofable through LLM-controlled tool args.createDelegateTool/DelegationDepthExceededError/DelegateAgentOptionsexported from the package; README usage example added.FlowExecutor's perspective — no newFlowSteptype needed.Codebase adaptations
AgentExecutoris static (AgentExecutor.execute(options)), so delegation re-enters it directly rather than instantiating anything.ToolDescriptorwraps a Vercel AI SDKtool, built via the same pattern as existing built-in tools.Validation results — a real safety gap found and fixed
QA didn't just unit-test the
maxDepthguard in isolation — it drove a genuinely pathological scenario: two agents with mock providers that unconditionally request delegation to each other, through realAgentExecutor.execute()calls. This exposed a real resource-exhaustion bug:AgentExecutor's tool-error handling swallows any thrown tool error (includingDelegationDepthExceededError) into a{error}tool-result message fed back to the LLM, so the depth guard fired correctly a few hops down but the error just became more conversational fodder — the runaway didn't stop, it just moved down a level and kept going until every nested level separately exhausted its ownmaxSteps. Measured: 155 LLM calls before termination atmaxDepth=2/maxSteps=5, 780 calls atmaxDepth=3/maxSteps=5— consistent with O(maxSteps^maxDepth) growth, with zero error signal reaching the original caller.Fixed with a
PropagatingToolErrormarker class thatDelegationDepthExceededErrornow extends —AgentExecutor.executeToolCallrethrows these instead of swallowing them, so the error propagates cleanly through every nesting level and the top-levelexecute()call genuinely rejects. Same scenario after the fix: 3 LLM calls total, top-levelexecute()rejects withDelegationDepthExceededError. Ordinary tool errors are unaffected — still converted to{error}tool-result messages exactly as before.npx vitest run: 492 passing. 3 known pre-existing provider-suite failures (missing optional peer deps) — unrelated to this epic.npx tsc --noEmit: clean.npm run build: exit 0; delegation exports resolve correctly from the built package.🤖 Generated with Claude Code