Skip to content

Commit 387962f

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 6808f99 commit 387962f

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
@@ -337,6 +337,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
337337
run: runSubAgent,
338338
sessions: fleetSessions,
339339
fleetRecords,
340+
...(sa.useWorktree !== undefined ? { useWorktree: sa.useWorktree } : {}),
340341
...(sa.onEvent !== undefined ? { onEvent: sa.onEvent } : {}),
341342
...(sa.onProgress !== undefined ? { onProgress: sa.onProgress } : {}),
342343
...(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
@@ -922,3 +922,69 @@ describe("list_agents", () => {
922922
gate.resolve({ report: "done" });
923923
});
924924
});
925+
926+
describe("spawn_agent parity with task", () => {
927+
test("refuses skywalker as a spawned worker", async () => {
928+
const deps = makeDeps(async () => ({ report: "no" }));
929+
const spawn = createSpawnAgentTool(deps);
930+
const raw = await callToolRaw(spawn, {
931+
description: "nope",
932+
prompt: "do it",
933+
agent: "skywalker",
934+
});
935+
expect(raw.isError).toBe(true);
936+
expect(raw.content).toContain("skywalker is the primary session identity");
937+
});
938+
939+
test("rejects a child outside this director allowlist", async () => {
940+
const deps = makeDeps(async () => ({ report: "no" }));
941+
deps.spawnAllowlist = ["intern", "explorer", "critic"];
942+
const spawn = createSpawnAgentTool(deps);
943+
const raw = await callToolRaw(spawn, {
944+
description: "build",
945+
prompt: "ship it",
946+
agent: "builder",
947+
});
948+
expect(raw.isError).toBe(true);
949+
expect(raw.content).toContain("allowlist");
950+
expect(raw.content).toContain("builder");
951+
});
952+
953+
test("a maySpawn director is launched as an orchestrator with nestedDispatch", async () => {
954+
const captured: RunSubAgentParams[] = [];
955+
const deps = makeDeps(async (params) => {
956+
captured.push(params);
957+
return { report: "ok" };
958+
});
959+
const spawn = createSpawnAgentTool(deps);
960+
await callTool(spawn, {
961+
description: "arch",
962+
prompt: "judge this",
963+
agent: "greybeard",
964+
});
965+
await new Promise((resolve) => setTimeout(resolve, 20));
966+
expect(captured).toHaveLength(1);
967+
expect(captured[0]!.orchestrator).toBe(true);
968+
expect(captured[0]!.orchestratorTier).toBe("nested-orchestrator");
969+
expect(captured[0]!.nestedDispatch).toBeDefined();
970+
expect(captured[0]!.nestedDispatch?.spawnAllowlist).toEqual(["intern", "explorer", "critic"]);
971+
});
972+
973+
test("allowOrchestrator false strips nested spawn even for maySpawn directors", async () => {
974+
const captured: RunSubAgentParams[] = [];
975+
const deps = makeDeps(async (params) => {
976+
captured.push(params);
977+
return { report: "ok" };
978+
});
979+
deps.allowOrchestrator = false;
980+
const spawn = createSpawnAgentTool(deps);
981+
await callTool(spawn, {
982+
description: "arch",
983+
prompt: "judge this",
984+
agent: "greybeard",
985+
});
986+
await new Promise((resolve) => setTimeout(resolve, 20));
987+
expect(captured[0]!.orchestrator).toBeUndefined();
988+
expect(captured[0]!.nestedDispatch).toBeUndefined();
989+
});
990+
});

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 {
@@ -346,6 +355,14 @@ export type AgentFleetDeps = SubAgentSandboxDeps & {
346355
* Omit on the primary session — its children are top-level.
347356
*/
348357
parentSessionId?: string;
358+
/** When set, only these director ids may be spawned. */
359+
spawnAllowlist?: readonly string[];
360+
/** When false, maySpawn directors cannot remount fleet verbs. Defaults true. */
361+
allowOrchestrator?: boolean;
362+
/** Isolate each spawn in a git worktree branched from dispatcher HEAD. */
363+
useWorktree?: boolean;
364+
/** Optional wall-clock budget (ms) forwarded to runSubAgent. */
365+
deadlineMs?: number;
349366
settings?: Settings | (() => Settings | undefined);
350367
catalog?: readonly ProviderCatalogEntry[] | (() => readonly ProviderCatalogEntry[]);
351368
onEvent?: (event: ReactorEmittedEvent) => void;
@@ -373,6 +390,7 @@ export function resolveDirectorDispatch(
373390
systemPromptRole: string;
374391
capabilities: ReturnType<typeof packageToCapabilities>;
375392
roleDefault: ReturnType<typeof defaultEffortForDirector>;
393+
pkg: DirectorPackage;
376394
}
377395
| { ok: false; error: string } {
378396
if (agentId !== undefined && agentId.length > 0) {
@@ -391,6 +409,7 @@ export function resolveDirectorDispatch(
391409
systemPromptRole: formatDirectorSystemPrompt(pkg),
392410
capabilities: packageToCapabilities(pkg),
393411
roleDefault: defaultEffortForDirector(pkg),
412+
pkg,
394413
};
395414
}
396415
if (intent !== undefined) {
@@ -403,6 +422,7 @@ export function resolveDirectorDispatch(
403422
systemPromptRole: formatDirectorSystemPrompt(pkg),
404423
capabilities: packageToCapabilities(pkg),
405424
roleDefault: defaultEffortForDirector(pkg),
425+
pkg,
406426
};
407427
}
408428
return {
@@ -451,12 +471,34 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
451471

452472
const resolved = resolveDirectorDispatch(agentId, intent);
453473
if (!resolved.ok) return fleetResult(call.id, resolved.error);
474+
if (agentId === "skywalker" || resolved.directorId === "skywalker") {
475+
return fleetResult(
476+
call.id,
477+
"Error: skywalker is the primary session identity, not a spawned worker. Pass spawn_agent(agent=…) for a specialist (builder, explorer, counsel, critic, …).",
478+
);
479+
}
480+
if (deps.spawnAllowlist !== undefined && deps.spawnAllowlist.length > 0) {
481+
if (!deps.spawnAllowlist.includes(resolved.directorId)) {
482+
return fleetResult(
483+
call.id,
484+
`Error: spawn of "${resolved.directorId}" is outside this director's allowlist. Allowed: ${deps.spawnAllowlist.join(", ")}.`,
485+
);
486+
}
487+
}
454488

455489
const settings = deps.settings !== undefined ? resolveDep(deps.settings) : undefined;
456490

491+
const orchestrator = resolved.pkg.spawn.maySpawn === true && deps.allowOrchestrator !== false;
492+
const nestedSpawnAllowlist =
493+
orchestrator &&
494+
resolved.pkg.spawn.allowlist !== undefined &&
495+
resolved.pkg.spawn.allowlist.length > 0
496+
? resolved.pkg.spawn.allowlist
497+
: undefined;
498+
457499
let provider: SubAgentProvider = resolveDep(deps.provider);
458500
const effort = resolveEffortForRole({
459-
orchestrator: false,
501+
orchestrator,
460502
roleDefault: resolved.roleDefault,
461503
...(provider.reasoningEffort !== undefined
462504
? { parentEffort: provider.reasoningEffort }
@@ -503,6 +545,52 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
503545
};
504546

505547
const catalog = deps.catalog !== undefined ? resolveDep(deps.catalog) : undefined;
548+
let worktreeCwd: string | undefined;
549+
let worktreeStashBaseline: readonly string[] | null = [];
550+
let worktreeHeadAtCreate: string | undefined;
551+
if (deps.useWorktree === true) {
552+
const worktreePath = join(deps.getWorkdirBase(), "worktrees", generateSessionId());
553+
try {
554+
const worktree = await createSubAgentWorktree(deps.cwd, worktreePath);
555+
worktreeCwd = worktree.path;
556+
worktreeStashBaseline = worktree.stashBaseline;
557+
worktreeHeadAtCreate = worktree.headAtCreate;
558+
} catch (err) {
559+
const message =
560+
err instanceof WorktreeError
561+
? err.message
562+
: `sub-agent worktree setup failed: ${err instanceof Error ? err.message : String(err)}`;
563+
deps.fleetRecords.reject(session.id, message);
564+
deps.sessions.fail(session.id, message);
565+
return fleetResult(call.id, `Error: ${message}`);
566+
}
567+
}
568+
569+
const nestedDispatch: NestedDispatchDeps | undefined = orchestrator
570+
? {
571+
permissionGate: deps.permissionGate,
572+
...(deps.inheritMcpTools !== undefined
573+
? { inheritMcpTools: deps.inheritMcpTools }
574+
: {}),
575+
...(deps.shellTimeout !== undefined ? { shellTimeout: deps.shellTimeout } : {}),
576+
...(deps.shellEnv !== undefined ? { shellEnv: deps.shellEnv } : {}),
577+
...(deps.extraToolPlugins !== undefined
578+
? { extraToolPlugins: deps.extraToolPlugins }
579+
: {}),
580+
...(deps.getBlobReader !== undefined ? { getBlobReader: deps.getBlobReader } : {}),
581+
getWorkdirBase: deps.getWorkdirBase,
582+
provider: deps.provider,
583+
...(deps.onEvent !== undefined ? { onEvent: deps.onEvent } : {}),
584+
...(deps.onProgress !== undefined ? { onProgress: deps.onProgress } : {}),
585+
sessions: deps.sessions,
586+
...(settings !== undefined ? { settings } : {}),
587+
...(catalog !== undefined ? { catalog } : {}),
588+
parentSessionId: session.id,
589+
...(deps.useWorktree !== undefined ? { useWorktree: deps.useWorktree } : {}),
590+
...(nestedSpawnAllowlist !== undefined ? { spawnAllowlist: nestedSpawnAllowlist } : {}),
591+
}
592+
: undefined;
593+
506594
const params: RunSubAgentParams = {
507595
// Name the trace directory after the session-store id so the
508596
// descendant-scoping check behind read_agent_trace can resolve this
@@ -514,7 +602,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
514602
...(deps.shellEnv !== undefined ? { shellEnv: deps.shellEnv } : {}),
515603
...(deps.extraToolPlugins !== undefined ? { extraToolPlugins: deps.extraToolPlugins } : {}),
516604
...(deps.getBlobReader !== undefined ? { getBlobReader: deps.getBlobReader } : {}),
517-
cwd: deps.cwd,
605+
cwd: worktreeCwd ?? deps.cwd,
518606
workdirBase: deps.getWorkdirBase(),
519607
provider,
520608
...(settings !== undefined ? { settings } : {}),
@@ -533,6 +621,18 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
533621
...(resolved.capabilities !== undefined ? { capabilities: resolved.capabilities } : {}),
534622
systemPromptRole: resolved.systemPromptRole,
535623
directorId: resolved.directorId,
624+
...(orchestrator
625+
? {
626+
orchestrator: true,
627+
orchestratorTier: resolved.pkg.tier,
628+
nestedDispatch: nestedDispatch!,
629+
}
630+
: {}),
631+
...(deps.deadlineMs !== undefined ? { deadlineMs: deps.deadlineMs } : {}),
632+
tier: resolved.pkg.tier,
633+
...(resolved.pkg.reportContract?.outputType !== undefined
634+
? { reportType: resolved.pkg.reportContract.outputType }
635+
: {}),
536636
// Keep the session open after a clean completion, and hand the
537637
// store a bounded close for close_agent to call later.
538638
persist: true,
@@ -594,6 +694,15 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
594694
status: deps.sessions.get(session.id)?.status ?? "completed",
595695
duration_ms: Date.now() - startedAt,
596696
});
697+
if (worktreeCwd === undefined) return;
698+
void cleanupSubAgentWorktree(deps.cwd, worktreeCwd, {
699+
stashBaseline: worktreeStashBaseline,
700+
...(worktreeHeadAtCreate !== undefined ? { headAtCreate: worktreeHeadAtCreate } : {}),
701+
}).catch((err: unknown) => {
702+
log.error("spawn_agent worktree cleanup failed: {error}", {
703+
error: err instanceof Error ? err.message : String(err),
704+
});
705+
});
597706
});
598707

599708
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
@@ -598,7 +598,10 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
598598
telemetry: liveTelemetry,
599599
sessions: fleetSessions,
600600
fleetRecords,
601+
allowOrchestrator: false,
601602
...(params.id !== undefined ? { parentSessionId: params.id } : {}),
603+
...(nd.useWorktree !== undefined ? { useWorktree: nd.useWorktree } : {}),
604+
...(nd.spawnAllowlist !== undefined ? { spawnAllowlist: nd.spawnAllowlist } : {}),
602605
...(nd.onEvent !== undefined ? { onEvent: nd.onEvent } : {}),
603606
...(nd.onProgress !== undefined ? { onProgress: nd.onProgress } : {}),
604607
...(nd.settings !== undefined ? { settings: nd.settings } : {}),

0 commit comments

Comments
 (0)