Skip to content

Commit 470ef2f

Browse files
committed
Make director task() a spawn_agent plus wait_agents wrapper
Closed-director task() was a second full spawn engine. When a session store is present it now starts the worker through spawn_agent and blocks on wait_agents, so one mailbox owns completion. Custom AgentProfile lookup still uses the legacy await-run path.
1 parent 61ef1d0 commit 470ef2f

5 files changed

Lines changed: 248 additions & 12 deletions

File tree

src/agent/tools.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
282282
const orchestratorTools: AgentTool[] = [];
283283
if (subAgentsEnabled && args.subAgent !== undefined) {
284284
const sa = args.subAgent;
285+
const fleetRecords = sa.sessions !== undefined ? createFleetRecords() : undefined;
285286
orchestratorTools.push(
286287
createTaskTool({
287288
cwd,
@@ -302,6 +303,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
302303
...(args.getBlobReader !== undefined ? { getBlobReader: args.getBlobReader } : {}),
303304
...(sa.useWorktree !== undefined ? { useWorktree: sa.useWorktree } : {}),
304305
...(args.telemetry !== undefined ? { telemetry: args.telemetry } : {}),
306+
...(fleetRecords !== undefined ? { fleetRecords } : {}),
305307
}),
306308
);
307309
if (sa.profiles !== undefined) {
@@ -321,9 +323,8 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
321323
// Mirror nested runSubAgent's orchestrator fleet mount (run.ts), but
322324
// reuse the existing TUI/exec session store — do not allocate a private
323325
// store only for these verbs. spawnAllowlist stays unwired on primary.
324-
if (sa.sessions !== undefined) {
326+
if (sa.sessions !== undefined && fleetRecords !== undefined) {
325327
const fleetSessions = sa.sessions;
326-
const fleetRecords = createFleetRecords();
327328
const fleetDeps = {
328329
permissionGate,
329330
inheritMcpTools: () => inheritedMcpTools,

src/subagent/agent-fleet.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,8 @@ export type AgentFleetDeps = SubAgentSandboxDeps & {
363363
useWorktree?: boolean;
364364
/** Optional wall-clock budget (ms) forwarded to runSubAgent. */
365365
deadlineMs?: number;
366+
/** When false, tear the worker down on completion (task wrapper). Default true. */
367+
persist?: boolean;
366368
settings?: Settings | (() => Settings | undefined);
367369
catalog?: readonly ProviderCatalogEntry[] | (() => readonly ProviderCatalogEntry[]);
368370
onEvent?: (event: ReactorEmittedEvent) => void;
@@ -660,7 +662,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
660662
// stays alive for followup (agentRetained / interrupt keep-alive) —
661663
// matching run.ts's persisting gate so followup_task does not hit a
662664
// removed cwd.
663-
persist: true,
665+
persist: deps.persist !== false,
664666
onAgentReady: ({ close, interrupt, followup, deliver }) => {
665667
deps.sessions.registerClose(session.id, async (deadlineMs) => {
666668
try {

src/subagent/run.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,8 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
521521
);
522522
}
523523
const nd = params.nestedDispatch;
524+
const fleetSessions = nd.sessions ?? createSubAgentSessionStore();
525+
const fleetRecords = createFleetRecords();
524526
tools = [
525527
...tools,
526528
createTaskTool({
@@ -542,7 +544,8 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
542544
telemetry: liveTelemetry,
543545
...(nd.onEvent !== undefined ? { onEvent: nd.onEvent } : {}),
544546
...(nd.onProgress !== undefined ? { onProgress: nd.onProgress } : {}),
545-
...(nd.sessions !== undefined ? { sessions: nd.sessions } : {}),
547+
sessions: fleetSessions,
548+
fleetRecords,
546549
...(nd.settings !== undefined ? { settings: nd.settings } : {}),
547550
...(nd.catalog !== undefined ? { catalog: nd.catalog } : {}),
548551
...(nd.profiles !== undefined ? { profiles: nd.profiles } : {}),
@@ -569,16 +572,9 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
569572
createReadAgentTraceTool(nd.getWorkdirBase, {
570573
actorId: params.id,
571574
tier,
572-
getNodes: () => nd.sessions?.list() ?? [],
575+
getNodes: () => fleetSessions.list(),
573576
}),
574577
];
575-
// spawn_agent/wait_agents need a session store as their mailbox;
576-
// reuse the orchestrator's if it has one, else give this install its
577-
// own. fleetRecords holds terminal results the session store's
578-
// display cap would otherwise evict before wait_agents collects them
579-
// (see agent-fleet.ts).
580-
const fleetSessions = nd.sessions ?? createSubAgentSessionStore();
581-
const fleetRecords = createFleetRecords();
582578
const lifecycleAuthority = {
583579
actorId: params.id,
584580
tier,

src/subagent/task-tool.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import { tool } from "@intx/agent";
66
import type { AgentTool } from "@intx/agent";
77
import { type } from "arktype";
88
import type { ReactorEmittedEvent } from "@intx/inference";
9+
import { getLogger } from "@intx/log";
910
import type { ToolDefinition, ToolResult } from "@intx/types/runtime";
1011

12+
import { LOG_NAMESPACE_ROOT } from "../branding.js";
13+
1114
import { runtimeSettingsWithCatalog, type ProviderCatalogEntry } from "../config/index.js";
1215
import { formatSubAgentTaskAuthFailureMessage } from "./inference-auth-failure.js";
1316
import type { CapabilityFilter, AgentProfile } from "../agent/profiles.js";
@@ -30,6 +33,14 @@ import {
3033
} from "../provider/reasoning-effort.js";
3134
import { isCodexProviderName } from "../config/codex-providers.js";
3235
import { DEFAULT_CANCEL_REASON, type SubAgentSessionStore } from "./session-store.js";
36+
import {
37+
createFleetRecords,
38+
createSpawnAgentTool,
39+
createWaitAgentsTool,
40+
MAX_WAIT_TIMEOUT_MS,
41+
type AgentFleetDeps,
42+
type FleetRecordsHandle,
43+
} from "./agent-fleet.js";
3344
import { buildDispatchBrief, type TaskIntent } from "./report.js";
3445
import { appendSubAgentParentHints, type ForcedStopReason } from "./stop-policy.js";
3546
import {
@@ -55,6 +66,8 @@ import type {
5566
SubAgentSandboxDeps,
5667
} from "./types.js";
5768

69+
const log = getLogger([LOG_NAMESPACE_ROOT, "subagent", "task-tool"]);
70+
5871
export const TaskToolArgs = type({
5972
description: "string",
6073
prompt: "string",
@@ -186,6 +199,8 @@ export type TaskToolDeps = SubAgentSandboxDeps & {
186199
// Records sub-agent starts and outcomes. Injected so the tool has no
187200
// process-wide dependency; omitting it makes dispatch silent.
188201
telemetry?: Telemetry;
202+
/** Shared with spawn_agent/wait_agents when this task tool is fleet-backed. */
203+
fleetRecords?: FleetRecordsHandle;
189204
};
190205

191206
function taskToolResult(
@@ -249,11 +264,159 @@ function requiredTaskFieldsError(
249264
return message;
250265
}
251266

267+
async function runTaskViaFleet(input: {
268+
callId: string;
269+
signal: AbortSignal;
270+
description: string;
271+
prompt: string;
272+
context: string | undefined;
273+
agentId: string | undefined;
274+
goals: string[];
275+
intent: TaskIntent | undefined;
276+
successCriteria: string[];
277+
doNot: string[];
278+
reportFocus: string | undefined;
279+
deps: TaskToolDeps;
280+
sessions: SubAgentSessionStore;
281+
fleetRecords: FleetRecordsHandle;
282+
}): Promise<ToolResult> {
283+
const fleetDeps: AgentFleetDeps = {
284+
permissionGate: input.deps.permissionGate,
285+
...(input.deps.inheritMcpTools !== undefined
286+
? { inheritMcpTools: input.deps.inheritMcpTools }
287+
: {}),
288+
...(input.deps.shellTimeout !== undefined ? { shellTimeout: input.deps.shellTimeout } : {}),
289+
...(input.deps.shellEnv !== undefined ? { shellEnv: input.deps.shellEnv } : {}),
290+
...(input.deps.extraToolPlugins !== undefined
291+
? { extraToolPlugins: input.deps.extraToolPlugins }
292+
: {}),
293+
...(input.deps.getBlobReader !== undefined ? { getBlobReader: input.deps.getBlobReader } : {}),
294+
cwd: input.deps.cwd,
295+
getWorkdirBase: input.deps.getWorkdirBase,
296+
provider: input.deps.provider,
297+
run: input.deps.run,
298+
sessions: input.sessions,
299+
fleetRecords: input.fleetRecords,
300+
persist: false,
301+
...(input.deps.parentSessionId !== undefined
302+
? { parentSessionId: input.deps.parentSessionId }
303+
: {}),
304+
...(input.deps.spawnAllowlist !== undefined
305+
? { spawnAllowlist: input.deps.spawnAllowlist }
306+
: {}),
307+
...(input.deps.allowOrchestrator !== undefined
308+
? { allowOrchestrator: input.deps.allowOrchestrator }
309+
: {}),
310+
...(input.deps.useWorktree !== undefined ? { useWorktree: input.deps.useWorktree } : {}),
311+
...(input.deps.deadlineMs !== undefined ? { deadlineMs: input.deps.deadlineMs } : {}),
312+
...(input.deps.settings !== undefined ? { settings: input.deps.settings } : {}),
313+
...(input.deps.catalog !== undefined ? { catalog: input.deps.catalog } : {}),
314+
...(input.deps.onEvent !== undefined ? { onEvent: input.deps.onEvent } : {}),
315+
...(input.deps.onProgress !== undefined ? { onProgress: input.deps.onProgress } : {}),
316+
...(input.deps.telemetry !== undefined ? { telemetry: input.deps.telemetry } : {}),
317+
};
318+
const spawn = createSpawnAgentTool(fleetDeps);
319+
const wait = createWaitAgentsTool({
320+
sessions: input.sessions,
321+
fleetRecords: input.fleetRecords,
322+
});
323+
if (spawn.kind !== "full" || wait.kind !== "full") {
324+
return taskToolResult(input.callId, "Error: fleet tools are unavailable.");
325+
}
326+
const started = await spawn.handler(
327+
{
328+
id: input.callId,
329+
name: "spawn_agent",
330+
arguments: {
331+
description: input.description,
332+
prompt: input.prompt,
333+
...(input.context !== undefined ? { context: input.context } : {}),
334+
...(input.agentId !== undefined ? { agent: input.agentId } : {}),
335+
...(input.goals.length > 0 ? { goals: input.goals } : {}),
336+
...(input.intent !== undefined ? { intent: input.intent } : {}),
337+
...(input.successCriteria.length > 0 ? { success_criteria: input.successCriteria } : {}),
338+
...(input.doNot.length > 0 ? { do_not: input.doNot } : {}),
339+
...(input.reportFocus !== undefined ? { report_focus: input.reportFocus } : {}),
340+
},
341+
},
342+
input.signal,
343+
);
344+
const startedText =
345+
typeof started.content === "string" ? started.content : JSON.stringify(started.content);
346+
if (started.isError === true || startedText.startsWith("Error:")) {
347+
return taskToolResult(input.callId, startedText);
348+
}
349+
let agentId: string;
350+
try {
351+
const parsed = JSON.parse(startedText) as { agent_id?: unknown };
352+
if (typeof parsed.agent_id !== "string" || parsed.agent_id.length === 0) {
353+
return taskToolResult(input.callId, "Error: spawn_agent returned no agent_id.");
354+
}
355+
agentId = parsed.agent_id;
356+
} catch (err) {
357+
log.error("spawn_agent payload was not JSON: {error}", {
358+
error: err instanceof Error ? err.message : String(err),
359+
});
360+
return taskToolResult(
361+
input.callId,
362+
`Error: spawn_agent returned invalid payload: ${startedText}`,
363+
);
364+
}
365+
366+
while (!input.signal.aborted) {
367+
const waited = await wait.handler(
368+
{
369+
id: `${input.callId}-wait`,
370+
name: "wait_agents",
371+
arguments: { targets: [agentId], mode: "all", timeout_ms: MAX_WAIT_TIMEOUT_MS },
372+
},
373+
input.signal,
374+
);
375+
const waitedText =
376+
typeof waited.content === "string" ? waited.content : JSON.stringify(waited.content);
377+
if (waited.isError === true || waitedText.startsWith("Error:")) {
378+
return taskToolResult(input.callId, waitedText);
379+
}
380+
let payload: {
381+
timed_out?: boolean;
382+
results?: { status?: string; report?: string; error?: string }[];
383+
};
384+
try {
385+
payload = JSON.parse(waitedText) as typeof payload;
386+
} catch (err) {
387+
log.error("wait_agents payload was not JSON: {error}", {
388+
error: err instanceof Error ? err.message : String(err),
389+
});
390+
return taskToolResult(
391+
input.callId,
392+
`Error: wait_agents returned invalid payload: ${waitedText}`,
393+
);
394+
}
395+
if (payload.timed_out === true) continue;
396+
const result = payload.results?.[0];
397+
if (result === undefined) {
398+
return taskToolResult(input.callId, `Error: wait_agents returned no result for ${agentId}.`);
399+
}
400+
if (result.status === "failed") {
401+
return taskToolResult(
402+
input.callId,
403+
`Error: sub-agent "${input.description}" failed: ${result.error ?? "unknown error"}`,
404+
);
405+
}
406+
const report = result.report ?? "";
407+
return taskToolResult(input.callId, `Sub-agent "${input.description}" reported:\n\n${report}`);
408+
}
409+
return taskToolResult(input.callId, `Sub-agent "${input.description}" cancelled by operator.`);
410+
}
411+
252412
export function createTaskTool(deps: TaskToolDeps): AgentTool {
253413
const run = deps.run;
254414
const telemetry = deps.telemetry ?? NOOP_TELEMETRY;
255415
// Session-scoped re-dispatch ledger: one per parent task tool instance.
256416
const briefLedger = createBriefDispatchLedger();
417+
const fleetSessions = deps.sessions;
418+
const fleetRecords =
419+
deps.fleetRecords ?? (fleetSessions !== undefined ? createFleetRecords() : undefined);
257420
// Every completed dispatch gets an outcome record — the log otherwise
258421
// carries shape and run state but never what the run actually produced.
259422
// Tagged with the dispatched child's provider/model/family so
@@ -334,6 +497,32 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
334497
return taskToolResult(call.id, requiredTaskFieldsError(args, empty));
335498
}
336499

500+
// Closed-director task() is spawn_agent + wait_agents. Custom profiles
501+
// still use the legacy await-run path until spawn grows profile lookup.
502+
const agentForFleet = typeof args.agent === "string" ? args.agent : undefined;
503+
const canUseFleet =
504+
fleetSessions !== undefined &&
505+
fleetRecords !== undefined &&
506+
(agentForFleet === undefined || agentForFleet.length === 0 || isDirectorId(agentForFleet));
507+
if (canUseFleet) {
508+
return await runTaskViaFleet({
509+
callId: call.id,
510+
signal,
511+
description,
512+
prompt,
513+
context,
514+
agentId,
515+
goals,
516+
intent,
517+
successCriteria,
518+
doNot,
519+
reportFocus,
520+
deps,
521+
sessions: fleetSessions,
522+
fleetRecords,
523+
});
524+
}
525+
337526
let provider: SubAgentProvider =
338527
typeof deps.provider === "function" ? deps.provider() : deps.provider;
339528
// Snapshot parent effort before profile-inference rebuilds so role-default
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { createTaskTool } from "./task-tool.js";
4+
import { createFleetRecords } from "./agent-fleet.js";
5+
import { createSubAgentSessionStore } from "./session-store.js";
6+
import { createPermissionGate } from "../permission/gate.js";
7+
8+
const testPermissionGate = createPermissionGate({
9+
approvals: [],
10+
interactive: false,
11+
skipPermissions: true,
12+
});
13+
14+
const provider = {
15+
providerName: "test-provider",
16+
baseURL: "http://localhost",
17+
model: "test-model",
18+
};
19+
20+
describe("task via spawn_agent + wait_agents", () => {
21+
test("a director task with a session store returns the worker report", async () => {
22+
const sessions = createSubAgentSessionStore();
23+
const fleetRecords = createFleetRecords();
24+
const tool = createTaskTool({
25+
permissionGate: testPermissionGate,
26+
cwd: "/tmp",
27+
getWorkdirBase: () => "/tmp/workdir",
28+
provider,
29+
sessions,
30+
fleetRecords,
31+
run: async () => ({
32+
report: "## Summary\nshipped\n## Findings\nok\n## Blockers\n\n## Paths\n",
33+
}),
34+
});
35+
if (tool.kind !== "full") throw new Error("expected full tool");
36+
const result = await tool.handler(
37+
{
38+
id: "t1",
39+
name: "task",
40+
arguments: { description: "ship", prompt: "do it", intent: "explore" },
41+
},
42+
new AbortController().signal,
43+
);
44+
const content = typeof result.content === "string" ? result.content : "";
45+
expect(content).toContain('Sub-agent "ship" reported');
46+
expect(content).toContain("shipped");
47+
});
48+
});

0 commit comments

Comments
 (0)