Skip to content

LOU-D: Multi-agent delegation - #5

Merged
LinuxDevil merged 6 commits into
mainfrom
loushy/lou-d
Sep 28, 2026
Merged

LinuxDevil merged 6 commits into
mainfrom
loushy/lou-d

Conversation

@LinuxDevil

Copy link
Copy Markdown
Owner

Summary

Implements all 4 tickets from LOU-D (Phase 1), adding an agent-to-agent delegation primitive:

  • LOU-D1: createDelegateTool(opts: DelegateAgentOptions) wraps a child AgentConfig as a ToolDescriptor, invoking the child's own AgentExecutor.execute() with a clean context (contextMode: 'none', default) and returning its text/usage.
  • LOU-D2: contextMode: 'full-history' (opt-in, prepends passed context to the child's input) and a maxDepth guard against delegation loops (A delegates to B which delegates back to A). Depth is tracked via a module-scoped AsyncLocalStorage<number> so it propagates correctly across an A→B→A chain via Node's native async-context — not spoofable through LLM-controlled tool args.
  • LOU-D3: createDelegateTool/DelegationDepthExceededError/DelegateAgentOptions exported from the package; README usage example added.
  • LOU-D4: Verified a delegate tool behaves identically to a built-in tool from FlowExecutor's perspective — no new FlowStep type needed.

Codebase adaptations

AgentExecutor is static (AgentExecutor.execute(options)), so delegation re-enters it directly rather than instantiating anything. ToolDescriptor wraps a Vercel AI SDK tool, 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 maxDepth guard in isolation — it drove a genuinely pathological scenario: two agents with mock providers that unconditionally request delegation to each other, through real AgentExecutor.execute() calls. This exposed a real resource-exhaustion bug: AgentExecutor's tool-error handling swallows any thrown tool error (including DelegationDepthExceededError) 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 own maxSteps. Measured: 155 LLM calls before termination at maxDepth=2/maxSteps=5, 780 calls at maxDepth=3/maxSteps=5 — consistent with O(maxSteps^maxDepth) growth, with zero error signal reaching the original caller.

Fixed with a PropagatingToolError marker class that DelegationDepthExceededError now extends — AgentExecutor.executeToolCall rethrows these instead of swallowing them, so the error propagates cleanly through every nesting level and the top-level execute() call genuinely rejects. Same scenario after the fix: 3 LLM calls total, top-level execute() rejects with DelegationDepthExceededError. 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

LinuxDevil and others added 6 commits September 28, 2026 19:49
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>
@LinuxDevil
LinuxDevil merged commit 63d53e5 into main Sep 28, 2026
1 check failed
@LinuxDevil
LinuxDevil deleted the loushy/lou-d branch September 28, 2026 17:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant