Skip to content

Recursive Dispatch: When Agents Write the Plan and Other Agents Do the Work

Agents That Call Agents — recursive dispatch in practice

You ask an agent to audit 300 files for SQL injection. By turn 40, it’s lost the plot — skipping items, summarizing instead of doing, confidently reporting completion on work it never touched. This isn’t a smarter-model problem. It’s an architecture problem, and the fix is called recursive dispatch: one model writes the plan as code, and that code calls other models to do the work. Here’s how we wired it into a shipping product: why in-process WASM was the right sandbox, the five-second default timeout that made the feature look broken, how the trust model changes, and what the pattern costs in tokens and rate limits.

  • Recursive dispatch splits planning from doing. The main model writes orchestration code — a loop, a Promise.all, a filter — and that code calls worker models through a task() function. The driver only holds the shape of the plan; each worker gets a fresh context and one clear job. That’s what makes a 300-file review actually finish, instead of quietly falling apart at turn 40.
  • Your trust model changes. Once the model can fan out subagents from inside a sandbox, “the user approves every action” stops being the thing keeping you safe. Per-subagent filesystem permissions become the real control. Approval modals still fire where they matter — they’re just no longer carrying the whole load.
  • There’s no one right sandbox. In-process WASM (QuickJS) is right for an editor extension. Docker is right for a server. Modal is right for the cloud. The pattern doesn’t care — pick based on your latency budget and what you can reasonably ask of the user’s machine.

Recursive dispatch lets an agent write its plan as JavaScript and have that JavaScript call other LLMs. The driving model holds the plan; worker models do the work, each in a fresh context. We wired the pattern into CodeBuddy using LangChain’s deepagentsjs — their JavaScript agent framework, roughly the equivalent of LangGraph for Node — and its interpreter extension @langchain/quickjs. The pattern itself is stack-agnostic: LlamaIndex, native OpenAI SDKs, or a homegrown loop can all express it, given a sandboxed interpreter and a recursion primitive.


The 300-file case in the intro isn’t a one-off. It’s the same shape as

  • translate 84 UI strings into three languages,
  • audit every route handler for missing auth,
  • or any workload where N is bigger than the model’s working memory.

The model can call a task tool 300 times, one turn after another. What breaks isn’t the calling — it’s the bookkeeping.

The root cause is that the model is holding down two jobs at once — deciding what to do and tracking how much is done — and that second job has no good home in a context window that grows with every turn. Recursive dispatch is what happens when you give the second job to code instead.

A recursive language model (RLM) splits those two jobs apart. One model acts as the driver: it writes a plan as code — a loop, a Promise.all, a filter().map(). That code then calls other models as workers. The driver only ever holds the shape of the workload. Each worker gets a fresh context and one scoped task.

The idea keeps showing up around the ecosystem under different names. LangChain’s Deep Agents Building workflows for agents with Skills and Interpreters. AutoGen calls it code-executor orchestration. LangChain’s Deep Agents ships it as dynamic subagents, with an interpreter as the substrate and task() as the recursion primitive.

Here’s what it looks like in practice. In a Deep Agents runtime with subagents: true, the primary agent writes this code, and the sandbox runs it:

// The primary agent writes this code inside the QuickJS sandbox.
const files = ["/src/auth.ts", "/src/payment.ts", "/src/session.ts"];
const reviews = await Promise.all(
files.map((f) =>
task({
description: `Review ${f} for SQL injection. Cite line numbers.`,
subagentType: "reviewer",
responseSchema: {
type: "object",
properties: {
vulnerabilities: { type: "array", items: { type: "object" } },
},
},
}),
),
);

Three subagent LLMs run in parallel. Each gets its own context, its own tools, its own middleware. Their results come back into the driver’s JavaScript scope as typed values responseSchema, the driver can do whatever it wants with them: filter, sort, feed them into a synthesis pass. The bridge between the sandbox and the LLM layer is a promise, not text squeezed through a tool call.

If you’re on deepagentsjs, the whole integration is one middleware entry:

import { createCodeInterpreterMiddleware } from "@langchain/quickjs";
middleware.push(
createCodeInterpreterMiddleware({
subagents: true,
ptc: [
"get_diagnostics",
"ls",
"grep",
"search_symbols",
"search_vector_db",
],
maxPtcCalls: 64,
executionTimeoutMs: 300_000,
}),
);

Four options do the heavy lifting:

  • subagents: true installs the task() global inside the sandbox and wires it to the parent agent’s task tool. Set it to false and the interpreter still runs — you just get programmatic tool calling without the recursion.
  • ptc (programmatic tool-calling) is the allowlist of parent-agent tools exposed as async functions inside the sandbox. We keep ours read-only on purpose — the sandbox is a place for orchestration, not for writes.
  • maxPtcCalls: 64 caps how many tool calls a single eval can make. This stacks with the runtime’s own rate limiter (ours runs 200/min global, 50/min per-tool) — whichever is stricter wins.
  • executionTimeoutMs: 300_000 gives each eval five minutes of wall-clock time. The default is five seconds, and that difference cost us a confusing afternoon.

And here’s the full journey, from a user request to a dispatched subagent and back:

sequenceDiagram participant U as User participant P as Primary Agent<br/>(createDeepAgent) participant I as QuickJS Interpreter<br/>(CodeInterpreterMiddleware) participant T as Task Bridge<br/>(SubAgentMiddleware) participant S as Subagent<br/>(fresh ReactAgent) participant M as Modal / SecretStorage U->>P: "Review these 20 files" P->>I: eval("...Promise.all(files.map(f => task({...})))...") I->>T: task({ description, subagentType }) T->>S: invoke() with filtered tools + role prompt + permissions S->>M: Tool call (e.g. run_terminal_command) M-->>S: Approved / denied via vscode.window.showWarningMessage S-->>T: ToolMessage result T-->>I: Promise resolves with subagent output I-->>P: eval returns aggregated array P-->>U: Synthesized response

The interpreter and the task bridge live inside the primary agent’s middleware stack. Each subagent spins up for its dispatch, does its job, and is gone. Its filesystem tools respect whatever FilesystemPermission rules are attached to its spec.

Deep Agents ships a SandboxBackendProtocol, so the interpreter can be backed by QuickJS, Deno, Modal, or anything you write yourself. We picked QuickJS because Codebuddy is an editor extension.

  • It boots in under a millisecond, in-process. QuickJS is a WASM module that runs inside the VS Code extension host.
  • The isolation is real and the surface is tiny. No filesystem, no network, no shell — the only capabilities inside the sandbox are the host bindings you expose through ptc. That’s a tighter boundary than Node’s vm2 for the same threat model.
  • It’s free. A local WASM interpreter costs nothing per eval.

The nice part: the sandbox choice is completely decoupled from the pattern. A team that wants real-VM isolation can swap in Modal through the same protocol, and nothing in the orchestration code changes.

If you enable in-REPL subagent dispatch, you inherit a safety question from the interpreter layer.

HITL approvals do not survive sandboxed execution. No host binding — in any policy, present or future — may trigger an action that would normally require modal approval.

That rule made sense — right up until we read the fine print. “HITL” (human-in-the-loop approval) isn’t one mechanism. In a Deep Agents runtime there are at least three, and they behave very differently when task() is called from inside eval:

Approval mechanismBypassed by task() inside eval?
LangGraph interrupt_on wrapping the parent agent’s task toolYes
humanInTheLoopMiddleware({ interruptOn }) configured inside a subagent’s own middlewareNo — still fires
Approvals inside a tool’s own _call implementationNo — fires from any dispatch source

So the real risk isn’t modal bypass. It’s something quieter. Here’s the rule we enforce now:

The interpreter amplifies write blast radius via dynamic subagent dispatch. When subagents: true is set, a single REPL script can fan out to 32 concurrent task() calls (a hardcoded cap in @langchain/quickjs). Tool-boundary modals still fire; the risk is silent writes via deepagents’ backend-attached write_file / edit_file, which don’t route through modals. Any subagent visible to the REPL whose role does not require workspace writes must carry permissions: scoped tightly enough that a prompt-injected write cannot reach /workspace/** or sensitive /docs/** paths.

The sandbox protects your machine from the LLM’s code. Permissions protect your workspace from the LLM’s subagents. You need both.

Permissions are the real security primitive

Section titled “Permissions are the real security primitive”

Deep Agents ships a native permissions system that, honestly, most of the ecosystem hasn’t discovered yet. Every subagent spec accepts a FilesystemPermission[]:

export interface FilesystemPermission {
operations: readonly ("read" | "write")[];
paths: string[]; // absolute globs — "/**", "/docs/adr/**"
mode?: "allow" | "deny"; // default "allow"
}

Rules evaluate in declaration order, first match wins, permissive by default. enforcePermission() runs inside every filesystem tool’s _call, before the operation executes. On a deny, it throws permission denied for ${op} on ${path} — the subagent’s LLM sees the error as a tool result and learns not to try again.

We have eight subagent roles, and every single one carries an explicit policy:

export type PermissionPolicy = readonly FilesystemPermission[] | "permissive";
export const READ_ONLY_PERMISSIONS: readonly FilesystemPermission[] = [
{ operations: ["write"], paths: ["/", "/**"], mode: "deny" },
];
export const DOC_WRITER_PERMISSIONS: readonly FilesystemPermission[] = [
{ operations: ["write"], paths: ["/docs/**"], mode: "allow" },
{ operations: ["write"], paths: ["/", "/**"], mode: "deny" },
];
export const ROLE_PERMISSIONS: Readonly<
Record<SubagentRoleName, PermissionPolicy>
> = {
reviewer: READ_ONLY_PERMISSIONS,
"architecture-expert": READ_ONLY_PERMISSIONS,
"doc-writer": DOC_WRITER_PERMISSIONS,
architect: ARCHITECT_PERMISSIONS,
"code-analyzer": "permissive",
debugger: "permissive",
"file-organizer": "permissive",
tester: "permissive",
};
test("every role in SUBAGENT_ROLES has an explicit ROLE_PERMISSIONS entry", () => {
const missing = SUBAGENT_ROLES.map((r) => r.name).filter(
(name) => !(name in ROLE_PERMISSIONS),
);
assert.deepStrictEqual(missing, []);
});

Permissions in place. subagents: true flipped. First smoke test — dispatch a subagent from eval to read a file — timed out. Every dispatch. Every time.

The culprit turned out to be one constant in one file inside @langchain/quickjs:

export const DEFAULT_EXECUTION_TIMEOUT = 5_000;

And the eval loop that enforces it:

const deadline = timeoutMs < 0 ? Infinity : Date.now() + timeoutMs;
while (Date.now() < deadline) {
context.runtime.executePendingJobs();
const state = context.getPromiseState(result.value);
if (state.type === "fulfilled") return { ok: true, value: ... };
if (state.type === "rejected") return { ok: false, error: ... };
await new Promise((r) => setTimeout(r, 1));
}
return { ok: false, error: { message: "Promise timed out — execution interrupted" } };

The fix is one line: executionTimeoutMs: 300_000. Five minutes, which comfortably covers the library’s own recommended batch size of about ten concurrent subagents. We also made it user-configurable (codebuddy.experimental.codeInterpreterTimeoutSeconds, clamped between 30 and 600) for people whose subagent turns run long.

Here’s the pattern the Deep Agents docs recommend for cost-sensitive workloads — a cheap classification pass first, an expensive deep pass only where it’s warranted — expressed as a single eval:

// Stage 1: cheap classification pass across all inputs.
const tagged = await Promise.all(
files.map((f) =>
task({
description: `Classify ${f} as: handler | util | test | config. Flag if risky.`,
subagentType: "reviewer",
responseSchema: {
type: "object",
properties: {
kind: { type: "string" },
risky: { type: "boolean" },
},
required: ["kind", "risky"],
},
}).then((r) => ({ file: f, ...r })),
),
);
// Stage 2: deep review only for risky handlers — structured findings.
const risky = tagged.filter((t) => t.kind === "handler" && t.risky);
const reviews = await Promise.all(
risky.map((t) =>
task({
description: `Deep security review of ${t.file}. Cite line numbers.`,
subagentType: "reviewer",
responseSchema: {
type: "object",
properties: {
findings: {
type: "array",
items: {
type: "object",
properties: {
line: { type: "integer" },
severity: {
type: "string",
enum: ["low", "medium", "high", "critical"],
},
issue: { type: "string" },
},
required: ["line", "severity", "issue"],
},
},
},
required: ["findings"],
},
}).then((r) => ({ file: t.file, findings: r.findings })),
),
);
// The primary agent's driver code synthesizes the final structured report.
const critical = reviews.flatMap((r) =>
r.findings
.filter((f) => f.severity === "critical")
.map((f) => ({ ...f, file: r.file })),
);
return {
totalAudited: files.length,
deepReviewed: reviews.length,
criticalFindings: critical,
};

A few things worth noticing. The reviewer subagent carries READ_ONLY_PERMISSIONS, so if anything in either stage tries to write_file, it throws inside enforcePermission — the permissions layer is doing its job invisibly. The eval has 300 seconds to complete, which covers both parallel passes with room to spare. And the primary agent never holds twenty files in its attention. It holds one plan, and the interpreter does the iterating.

The recursive pattern amplifies LLM-side load in ways that don’t show up in the architecture diagrams — but very much show up on your invoice and in your latency graphs. Let’s be honest about them.

Rate limits belong to the provider, not to you. @langchain/quickjs caps concurrent task() fan-out at 32, but every one of those dispatches is a full LLM inference against the subagent’s provider. Anthropic’s limits are usage-tier gated; OpenAI’s are per-model, on both requests and tokens per minute; Gemini’s are per-project. A 32-way fan-out hits them exactly like 32 concurrent API requests would — because that’s what it is. The LangChain provider clients retry with exponential backoff on 429, so the burst does eventually complete. It just completes slower under rate pressure. If you’re not on your provider’s highest tier, the practical concurrent fan-out is more like 5–10 before backoff starts dominating your wall-clock time.

One precision worth making: the tool rate limiter we mentioned earlier (200/min global, 50/min per-tool) caps how often the primary agent calls tools. It does not — and cannot — gate the subagents’ outbound LLM calls. Those go straight from the provider client to the vendor.

There’s no client-side concurrency throttle yet. The sandbox runs your Promise.all exactly as written, up to the library’s 32-way ceiling. A p-limit-style semaphore inside task() — so you could pin concurrency at the framework layer instead of hand-writing rate-limit-aware scripts — is on the roadmap. Until it lands, if your provider tier is modest, keep your batch sizes modest too.

Q: Isn’t this just letting the LLM run arbitrary code?

Sort of — and that’s less scary than it sounds. The interpreter is a QuickJS WASM sandbox with no filesystem, no network, and no shell. Its only capabilities are (a) the tools you list in ptc, (b) task() if you’ve enabled subagents, and (c) console.log capture. The security conversation isn’t about the JavaScript — it’s about the capabilities you hand in. Which is exactly why our ptc list is read-only and every subagent carries a permissions policy.

Q: What happens if a subagent tries to write a file it shouldn’t?

Every filesystem tool — write_file, edit_file, ls, read_file, glob, grep — runs enforcePermission() before doing anything. If the subagent’s rules deny that write on that path, the tool call throws with a clear message, the LLM sees the error as a tool result, and it almost always moves on. This enforcement lives in the deepagents library itself — we just supply the rules.

Q: Can I try this pattern in a real agent today?

Yes. deepagentsjs + @langchain/quickjs is the reference implementation, and the integration described in this post ships in CodeBuddy (VS Code, Cursor, Windsurf, VSCodium). Set codebuddy.experimental.codeInterpreter: true and the primary agent starts using eval with task() for large-N workloads. If your subagent turns run long, bump codebuddy.experimental.codeInterpreterTimeoutSeconds.


Three open threads we’re actively pulling on:

  • Cost projection at the sandbox boundary. A static-analysis pass over an eval script that counts expected task() fan-outs and previews the marginal spend before execution. When a user approves a fan-out, they should be approving a dollar figure, not just an intention.
  • Client-side concurrency semaphores. A p-limit-style throttle inside task(), so concurrency gets pinned at the framework layer and driver scripts stop needing to know their provider’s usage tier.
  • Driver-tier model splitting. A config surface that lets the eval-writing driver run on a cheaper model than the synthesis pass and the subagents. Per-role subagent routing already exists; the interpreter middleware just needs to accept its own model override so plan-writing turns don’t pay reasoning-tier prices.
  • Alternative sandbox backends. Deno and Modal via the deepagents SandboxBackendProtocol — for teams whose isolation requirements outgrow an in-process WASM boundary, or whose workloads are heavy enough that a remote executor’s billing beats local latency.

Available in the VS Code Marketplace and Open VSX Registry.