Skip to content

Commit 5fc0e36

Browse files
committed
Give spawn_agent worktree, allowlist, and nested spawn
spawn_agent could not isolate a cwd, honor a parent spawn matrix, or mount fleet verbs on maySpawn directors. Those gaps kept task() as the only complete dispatch path. spawn_agent now carries the same isolation and spawn-matrix surface for closed directors.
1 parent 55a590b commit 5fc0e36

5 files changed

Lines changed: 302 additions & 7 deletions

File tree

src/agent/tools.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
335335
run: runSubAgent,
336336
sessions: fleetSessions,
337337
fleetRecords,
338+
...(sa.useWorktree !== undefined ? { useWorktree: sa.useWorktree } : {}),
338339
...(sa.onEvent !== undefined ? { onEvent: sa.onEvent } : {}),
339340
...(sa.onProgress !== undefined ? { onProgress: sa.onProgress } : {}),
340341
...(sa.settings !== undefined ? { settings: sa.settings } : {}),

src/subagent/agent-fleet.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,3 +572,69 @@ describe("interrupt_agent unblocks wait_agents", () => {
572572
expect(results[0]!.report).toContain("partial");
573573
});
574574
});
575+
576+
describe("spawn_agent parity with task", () => {
577+
test("refuses skywalker as a spawned worker", async () => {
578+
const deps = makeDeps(async () => ({ report: "no" }));
579+
const spawn = createSpawnAgentTool(deps);
580+
const raw = await callToolRaw(spawn, {
581+
description: "nope",
582+
prompt: "do it",
583+
agent: "skywalker",
584+
});
585+
expect(raw.isError).toBe(true);
586+
expect(raw.content).toContain("skywalker is the primary session identity");
587+
});
588+
589+
test("rejects a child outside this director allowlist", async () => {
590+
const deps = makeDeps(async () => ({ report: "no" }));
591+
deps.spawnAllowlist = ["intern", "explorer", "critic"];
592+
const spawn = createSpawnAgentTool(deps);
593+
const raw = await callToolRaw(spawn, {
594+
description: "build",
595+
prompt: "ship it",
596+
agent: "builder",
597+
});
598+
expect(raw.isError).toBe(true);
599+
expect(raw.content).toContain("allowlist");
600+
expect(raw.content).toContain("builder");
601+
});
602+
603+
test("a maySpawn director is launched as an orchestrator with nestedDispatch", async () => {
604+
const captured: RunSubAgentParams[] = [];
605+
const deps = makeDeps(async (params) => {
606+
captured.push(params);
607+
return { report: "ok" };
608+
});
609+
const spawn = createSpawnAgentTool(deps);
610+
await callTool(spawn, {
611+
description: "arch",
612+
prompt: "judge this",
613+
agent: "greybeard",
614+
});
615+
await new Promise((resolve) => setTimeout(resolve, 20));
616+
expect(captured).toHaveLength(1);
617+
expect(captured[0]!.orchestrator).toBe(true);
618+
expect(captured[0]!.orchestratorTier).toBe("nested-orchestrator");
619+
expect(captured[0]!.nestedDispatch).toBeDefined();
620+
expect(captured[0]!.nestedDispatch?.spawnAllowlist).toEqual(["intern", "explorer", "critic"]);
621+
});
622+
623+
test("allowOrchestrator false strips nested spawn even for maySpawn directors", async () => {
624+
const captured: RunSubAgentParams[] = [];
625+
const deps = makeDeps(async (params) => {
626+
captured.push(params);
627+
return { report: "ok" };
628+
});
629+
deps.allowOrchestrator = false;
630+
const spawn = createSpawnAgentTool(deps);
631+
await callTool(spawn, {
632+
description: "arch",
633+
prompt: "judge this",
634+
agent: "greybeard",
635+
});
636+
await new Promise((resolve) => setTimeout(resolve, 20));
637+
expect(captured[0]!.orchestrator).toBeUndefined();
638+
expect(captured[0]!.nestedDispatch).toBeUndefined();
639+
});
640+
});

src/subagent/agent-fleet.ts

Lines changed: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,21 +31,25 @@
3131
*
3232
* Argument shape intentionally mirrors `task()`'s (description/prompt/
3333
* context/goals/intent/success_criteria/do_not/report_focus) so a
34-
* caller can swap one for the other. Scope is deliberately narrower than
35-
* `task()` for this first cut: only closed-director dispatch (`agent=` a
36-
* director id, or `intent=`) is supported — no custom AgentProfile lookup,
37-
* no nested orchestration, no re-dispatch ledger. Those remain `task()`-only
38-
* for now; nothing here stops adding them later.
34+
* caller can swap one for the other. Closed-director dispatch also carries
35+
* task()'s isolation and spawn-matrix: worktree cwd, parent allowlist,
36+
* maySpawn nestedDispatch, and deadline. Custom AgentProfile lookup and the
37+
* re-dispatch ledger remain task()-only until task becomes a thin wrapper.
3938
*
4039
*/
4140

41+
import { join } from "node:path";
42+
4243
import { tool } from "@intx/agent";
4344
import type { AgentTool } from "@intx/agent";
4445
import { type } from "arktype";
4546
import type { ToolDefinition, ToolResult } from "@intx/types/runtime";
4647
import type { ReactorEmittedEvent } from "@intx/inference";
48+
import { getLogger } from "@intx/log";
4749

50+
import { LOG_NAMESPACE_ROOT } from "../branding.js";
4851
import type { ProviderCatalogEntry } from "../config/index.js";
52+
import { generateSessionId } from "../session/index.js";
4953
import {
5054
isDirectorId,
5155
packageToCapabilities,
@@ -61,13 +65,18 @@ import { isCodexProviderName } from "../config/codex-providers.js";
6165
import { buildDispatchBrief, type TaskIntent } from "./report.js";
6266
import type { SubAgentSessionStore } from "./session-store.js";
6367
import type {
68+
NestedDispatchDeps,
6469
RunSubAgentParams,
6570
RunSubAgentResult,
6671
SubAgentProvider,
6772
SubAgentSandboxDeps,
6873
} from "./types.js";
74+
import { cleanupSubAgentWorktree, createSubAgentWorktree, WorktreeError } from "./worktree.js";
6975
import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js";
7076
import { classifyAgentName } from "../telemetry/classify.js";
77+
import type { DirectorPackage } from "../agent/directors/types.js";
78+
79+
const log = getLogger([LOG_NAMESPACE_ROOT, "subagent", "agent-fleet"]);
7180

7281
/** Terminal (or running) record for one spawned agent, keyed by agent id. */
7382
interface FleetRecord {
@@ -326,6 +335,14 @@ export type AgentFleetDeps = SubAgentSandboxDeps & {
326335
* Omit on the primary session — its children are top-level.
327336
*/
328337
parentSessionId?: string;
338+
/** When set, only these director ids may be spawned. */
339+
spawnAllowlist?: readonly string[];
340+
/** When false, maySpawn directors cannot remount fleet verbs. Defaults true. */
341+
allowOrchestrator?: boolean;
342+
/** Isolate each spawn in a git worktree branched from dispatcher HEAD. */
343+
useWorktree?: boolean;
344+
/** Optional wall-clock budget (ms) forwarded to runSubAgent. */
345+
deadlineMs?: number;
329346
settings?: Settings | (() => Settings | undefined);
330347
catalog?: readonly ProviderCatalogEntry[] | (() => readonly ProviderCatalogEntry[]);
331348
onEvent?: (event: ReactorEmittedEvent) => void;
@@ -353,6 +370,7 @@ export function resolveDirectorDispatch(
353370
systemPromptRole: string;
354371
capabilities: ReturnType<typeof packageToCapabilities>;
355372
roleDefault: ReturnType<typeof defaultEffortForDirector>;
373+
pkg: DirectorPackage;
356374
}
357375
| { ok: false; error: string } {
358376
if (agentId !== undefined && agentId.length > 0) {
@@ -371,6 +389,7 @@ export function resolveDirectorDispatch(
371389
systemPromptRole: formatDirectorSystemPrompt(pkg),
372390
capabilities: packageToCapabilities(pkg),
373391
roleDefault: defaultEffortForDirector(pkg),
392+
pkg,
374393
};
375394
}
376395
if (intent !== undefined) {
@@ -383,6 +402,7 @@ export function resolveDirectorDispatch(
383402
systemPromptRole: formatDirectorSystemPrompt(pkg),
384403
capabilities: packageToCapabilities(pkg),
385404
roleDefault: defaultEffortForDirector(pkg),
405+
pkg,
386406
};
387407
}
388408
return {
@@ -431,12 +451,34 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
431451

432452
const resolved = resolveDirectorDispatch(agentId, intent);
433453
if (!resolved.ok) return fleetResult(call.id, resolved.error);
454+
if (agentId === "skywalker" || resolved.directorId === "skywalker") {
455+
return fleetResult(
456+
call.id,
457+
"Error: skywalker is the primary session identity, not a spawned worker. Pass spawn_agent(agent=…) for a specialist (builder, explorer, counsel, critic, …).",
458+
);
459+
}
460+
if (deps.spawnAllowlist !== undefined && deps.spawnAllowlist.length > 0) {
461+
if (!deps.spawnAllowlist.includes(resolved.directorId)) {
462+
return fleetResult(
463+
call.id,
464+
`Error: spawn of "${resolved.directorId}" is outside this director's allowlist. Allowed: ${deps.spawnAllowlist.join(", ")}.`,
465+
);
466+
}
467+
}
434468

435469
const settings = deps.settings !== undefined ? resolveDep(deps.settings) : undefined;
436470

471+
const orchestrator = resolved.pkg.spawn.maySpawn === true && deps.allowOrchestrator !== false;
472+
const nestedSpawnAllowlist =
473+
orchestrator &&
474+
resolved.pkg.spawn.allowlist !== undefined &&
475+
resolved.pkg.spawn.allowlist.length > 0
476+
? resolved.pkg.spawn.allowlist
477+
: undefined;
478+
437479
let provider: SubAgentProvider = resolveDep(deps.provider);
438480
const effort = resolveEffortForRole({
439-
orchestrator: false,
481+
orchestrator,
440482
roleDefault: resolved.roleDefault,
441483
...(provider.reasoningEffort !== undefined
442484
? { parentEffort: provider.reasoningEffort }
@@ -483,6 +525,52 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
483525
};
484526

485527
const catalog = deps.catalog !== undefined ? resolveDep(deps.catalog) : undefined;
528+
let worktreeCwd: string | undefined;
529+
let worktreeStashBaseline: readonly string[] | null = [];
530+
let worktreeHeadAtCreate: string | undefined;
531+
if (deps.useWorktree === true) {
532+
const worktreePath = join(deps.getWorkdirBase(), "worktrees", generateSessionId());
533+
try {
534+
const worktree = await createSubAgentWorktree(deps.cwd, worktreePath);
535+
worktreeCwd = worktree.path;
536+
worktreeStashBaseline = worktree.stashBaseline;
537+
worktreeHeadAtCreate = worktree.headAtCreate;
538+
} catch (err) {
539+
const message =
540+
err instanceof WorktreeError
541+
? err.message
542+
: `sub-agent worktree setup failed: ${err instanceof Error ? err.message : String(err)}`;
543+
deps.fleetRecords.reject(session.id, message);
544+
deps.sessions.fail(session.id, message);
545+
return fleetResult(call.id, `Error: ${message}`);
546+
}
547+
}
548+
549+
const nestedDispatch: NestedDispatchDeps | undefined = orchestrator
550+
? {
551+
permissionGate: deps.permissionGate,
552+
...(deps.inheritMcpTools !== undefined
553+
? { inheritMcpTools: deps.inheritMcpTools }
554+
: {}),
555+
...(deps.shellTimeout !== undefined ? { shellTimeout: deps.shellTimeout } : {}),
556+
...(deps.shellEnv !== undefined ? { shellEnv: deps.shellEnv } : {}),
557+
...(deps.extraToolPlugins !== undefined
558+
? { extraToolPlugins: deps.extraToolPlugins }
559+
: {}),
560+
...(deps.getBlobReader !== undefined ? { getBlobReader: deps.getBlobReader } : {}),
561+
getWorkdirBase: deps.getWorkdirBase,
562+
provider: deps.provider,
563+
...(deps.onEvent !== undefined ? { onEvent: deps.onEvent } : {}),
564+
...(deps.onProgress !== undefined ? { onProgress: deps.onProgress } : {}),
565+
sessions: deps.sessions,
566+
...(settings !== undefined ? { settings } : {}),
567+
...(catalog !== undefined ? { catalog } : {}),
568+
parentSessionId: session.id,
569+
...(deps.useWorktree !== undefined ? { useWorktree: deps.useWorktree } : {}),
570+
...(nestedSpawnAllowlist !== undefined ? { spawnAllowlist: nestedSpawnAllowlist } : {}),
571+
}
572+
: undefined;
573+
486574
const params: RunSubAgentParams = {
487575
// Name the trace directory after the session-store id so the
488576
// descendant-scoping check behind read_agent_trace can resolve this
@@ -494,7 +582,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
494582
...(deps.shellEnv !== undefined ? { shellEnv: deps.shellEnv } : {}),
495583
...(deps.extraToolPlugins !== undefined ? { extraToolPlugins: deps.extraToolPlugins } : {}),
496584
...(deps.getBlobReader !== undefined ? { getBlobReader: deps.getBlobReader } : {}),
497-
cwd: deps.cwd,
585+
cwd: worktreeCwd ?? deps.cwd,
498586
workdirBase: deps.getWorkdirBase(),
499587
provider,
500588
...(settings !== undefined ? { settings } : {}),
@@ -513,6 +601,18 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
513601
...(resolved.capabilities !== undefined ? { capabilities: resolved.capabilities } : {}),
514602
systemPromptRole: resolved.systemPromptRole,
515603
directorId: resolved.directorId,
604+
...(orchestrator
605+
? {
606+
orchestrator: true,
607+
orchestratorTier: resolved.pkg.tier,
608+
nestedDispatch: nestedDispatch!,
609+
}
610+
: {}),
611+
...(deps.deadlineMs !== undefined ? { deadlineMs: deps.deadlineMs } : {}),
612+
tier: resolved.pkg.tier,
613+
...(resolved.pkg.reportContract?.outputType !== undefined
614+
? { reportType: resolved.pkg.reportContract.outputType }
615+
: {}),
516616
// Keep the session open after a clean completion, and hand the
517617
// store a bounded close for close_agent to call later.
518618
persist: true,
@@ -573,6 +673,15 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
573673
status: deps.sessions.get(session.id)?.status ?? "completed",
574674
duration_ms: Date.now() - startedAt,
575675
});
676+
if (worktreeCwd === undefined) return;
677+
void cleanupSubAgentWorktree(deps.cwd, worktreeCwd, {
678+
stashBaseline: worktreeStashBaseline,
679+
...(worktreeHeadAtCreate !== undefined ? { headAtCreate: worktreeHeadAtCreate } : {}),
680+
}).catch((err: unknown) => {
681+
log.error("spawn_agent worktree cleanup failed: {error}", {
682+
error: err instanceof Error ? err.message : String(err),
683+
});
684+
});
576685
});
577686

578687
return fleetResult(call.id, JSON.stringify({ agent_id: session.id, status: "running" }));

src/subagent/run.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,10 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
585585
telemetry: liveTelemetry,
586586
sessions: fleetSessions,
587587
fleetRecords,
588+
allowOrchestrator: false,
588589
...(params.id !== undefined ? { parentSessionId: params.id } : {}),
590+
...(nd.useWorktree !== undefined ? { useWorktree: nd.useWorktree } : {}),
591+
...(nd.spawnAllowlist !== undefined ? { spawnAllowlist: nd.spawnAllowlist } : {}),
589592
...(nd.onEvent !== undefined ? { onEvent: nd.onEvent } : {}),
590593
...(nd.onProgress !== undefined ? { onProgress: nd.onProgress } : {}),
591594
...(nd.settings !== undefined ? { settings: nd.settings } : {}),

0 commit comments

Comments
 (0)