Skip to content

Commit 7b806d2

Browse files
committed
Mount fleet verbs on primary Skywalker
Advertise and mount spawn_agent, wait_agents, and lifecycle fleet tools on the primary toolset so non-blocking fan-out works without fused task(). Closes CL-7024
1 parent 02a3f85 commit 7b806d2

4 files changed

Lines changed: 206 additions & 42 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Primary createAgentToolset mounts the six fleet verbs beside task /
3+
* search_agents / read_agent_trace when subAgent (with the shared TUI
4+
* sessions store) is wired. Leaves / no-subAgent toolsets stay without them.
5+
*/
6+
import { mkdtempSync } from "node:fs";
7+
import { tmpdir } from "node:os";
8+
import { join } from "node:path";
9+
import { describe, expect, test } from "bun:test";
10+
11+
import { createSubAgentSessionStore } from "../subagent/session-store.js";
12+
13+
const FLEET_VERBS = [
14+
"spawn_agent",
15+
"wait_agents",
16+
"close_agent",
17+
"resume_agent",
18+
"interrupt_agent",
19+
"followup_task",
20+
] as const;
21+
22+
describe("primary fleet verb mount", () => {
23+
test("createAgentToolset registers the six fleet verbs when subAgent + sessions are set", async () => {
24+
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
25+
const { createAgentToolset } = await import("./tools.js");
26+
const permissionGate = {
27+
check: async () => ({ allowed: true }),
28+
getSkipPermissions: () => false,
29+
} as never;
30+
const sessions = createSubAgentSessionStore();
31+
32+
const toolset = await createAgentToolset({
33+
cwd,
34+
permissionGate,
35+
onOperatorGate: async () => ({ kind: "option", index: 0 }),
36+
subAgent: {
37+
provider: {
38+
providerName: "test",
39+
baseURL: "http://127.0.0.1:0",
40+
model: "test-model",
41+
},
42+
getWorkdirBase: () => cwd,
43+
sessions,
44+
},
45+
});
46+
const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name);
47+
expect(names).toContain("task");
48+
expect(names).toContain("read_agent_trace");
49+
for (const name of FLEET_VERBS) {
50+
expect(names).toContain(name);
51+
}
52+
await toolset.dispose();
53+
});
54+
55+
test("createAgentToolset omits fleet verbs when subAgent is not set", async () => {
56+
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
57+
const { createAgentToolset } = await import("./tools.js");
58+
const permissionGate = {
59+
check: async () => ({ allowed: true }),
60+
getSkipPermissions: () => false,
61+
} as never;
62+
63+
const toolset = await createAgentToolset({
64+
cwd,
65+
permissionGate,
66+
onOperatorGate: async () => ({ kind: "option", index: 0 }),
67+
});
68+
const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name);
69+
expect(names).not.toContain("task");
70+
for (const name of FLEET_VERBS) {
71+
expect(names).not.toContain(name);
72+
}
73+
await toolset.dispose();
74+
});
75+
});

src/agent/tool-search.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@ describe("createToolIndex", () => {
8383
);
8484
});
8585

86+
test("orchestrator mode advertises the six fleet verbs", () => {
87+
const advertised = advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY);
88+
for (const name of [
89+
"spawn_agent",
90+
"wait_agents",
91+
"close_agent",
92+
"resume_agent",
93+
"interrupt_agent",
94+
"followup_task",
95+
] as const) {
96+
expect(CORE_TOOL_NAMES).toContain(name);
97+
expect(advertised).toContain(name);
98+
}
99+
});
100+
86101
test("manage_tasks is advertised regardless of availability", () => {
87102
expect(coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY)).toContain("manage_tasks");
88103
});
@@ -219,6 +234,16 @@ describe("advertisedTools", () => {
219234
const prefix = advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY);
220235
expect(prefix).toContain("task");
221236
expect(prefix).toContain("search_agents");
237+
for (const name of [
238+
"spawn_agent",
239+
"wait_agents",
240+
"close_agent",
241+
"resume_agent",
242+
"interrupt_agent",
243+
"followup_task",
244+
] as const) {
245+
expect(prefix).toContain(name);
246+
}
222247
// advertisedTools only emits tools present in the registry; multi-agent
223248
// tools appear on the wire when createAgentToolset registers them.
224249
const names = advertisedTools(registry, [], prefix).map((d) => d.name);

src/agent/tool-search.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,28 @@ export const CORE_TOOL_NAMES: readonly string[] = [
3939
// round-trip. Catalog-only placement left the model discovering profiles then
4040
// failing on an unloaded task tool.
4141
"task",
42+
// Fleet verbs (non-blocking spawn + lifecycle). Mounted on primary when
43+
// subAgent is wired; advertised here so the model does not tool_search for
44+
// them. Package allowlists (ORCHESTRATOR_TOOLS / SKYWALKER_TOOLS) are a
45+
// separate, deferred change.
46+
"spawn_agent",
47+
"wait_agents",
48+
"close_agent",
49+
"resume_agent",
50+
"interrupt_agent",
51+
"followup_task",
4252
];
4353

44-
const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = ["search_agents", "task"];
54+
const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = [
55+
"search_agents",
56+
"task",
57+
"spawn_agent",
58+
"wait_agents",
59+
"close_agent",
60+
"resume_agent",
61+
"interrupt_agent",
62+
"followup_task",
63+
];
4564

4665
// Session-start facts that gate a core tool's advertisement. Each must be
4766
// knowable once, before the first inference call, and must never change for

src/agent/tools.ts

Lines changed: 86 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ import {
3939
type SubAgentProvider,
4040
type SubAgentSessionStore,
4141
} from "../subagent/index.js";
42+
import { createFleetRecords, createSpawnAgentTool, createWaitAgentsTool } from "../subagent/agent-fleet.js";
43+
import {
44+
createCloseAgentTool,
45+
createResumeAgentTool,
46+
createInterruptAgentTool,
47+
createFollowupTaskTool,
48+
} from "../subagent/lifecycle-tools.js";
4249
import { parseManageTasksArgs } from "./tasks.js";
4350
import { createListDirTool } from "../util/list-dir.js";
4451
import { createWebFetchTool } from "../tools/web-fetch.js";
@@ -263,6 +270,84 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
263270

264271
// Align the advertised run_shell timeout with shell-guard (no built-in default;
265272
// advertise settings.shell.timeoutMs when set).
273+
// Orchestrator tools (task / search / trace / fleet) are assembled once so the
274+
// fleet verbs can reuse the same sessions store the task tool already holds —
275+
// never a private mailbox allocated only for spawn_agent/wait_agents.
276+
const orchestratorTools: AgentTool[] = [];
277+
if (subAgentsEnabled && args.subAgent !== undefined) {
278+
const sa = args.subAgent;
279+
orchestratorTools.push(
280+
createTaskTool({
281+
cwd,
282+
getWorkdirBase: sa.getWorkdirBase,
283+
provider: sa.provider,
284+
permissionGate,
285+
inheritMcpTools: () => inheritedMcpTools,
286+
run: runSubAgent,
287+
...(shellTimeout !== undefined ? { shellTimeout } : {}),
288+
...(shellEnv !== undefined ? { shellEnv } : {}),
289+
...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}),
290+
...(sa.onEvent !== undefined ? { onEvent: sa.onEvent } : {}),
291+
...(sa.onProgress !== undefined ? { onProgress: sa.onProgress } : {}),
292+
...(sa.sessions !== undefined ? { sessions: sa.sessions } : {}),
293+
...(sa.settings !== undefined ? { settings: sa.settings } : {}),
294+
...(sa.catalog !== undefined ? { catalog: sa.catalog } : {}),
295+
...(sa.profiles !== undefined ? { profiles: sa.profiles } : {}),
296+
...(args.getBlobReader !== undefined ? { getBlobReader: args.getBlobReader } : {}),
297+
...(sa.useWorktree !== undefined ? { useWorktree: sa.useWorktree } : {}),
298+
...(args.telemetry !== undefined ? { telemetry: args.telemetry } : {}),
299+
}),
300+
);
301+
if (sa.profiles !== undefined) {
302+
orchestratorTools.push(
303+
createSearchAgentsTool(() => {
304+
const profiles = sa.profiles;
305+
return typeof profiles === "function" ? profiles() : (profiles ?? []);
306+
}),
307+
);
308+
}
309+
// Tier 1: the primary session is always an orchestrator and may
310+
// target any worker (assertCanTargetAgent's rule), so no authority
311+
// context is passed here — omitting it is treated as unrestricted,
312+
// matching Tier 1's actual authority.
313+
orchestratorTools.push(createReadAgentTraceTool(sa.getWorkdirBase));
314+
315+
// Mirror nested runSubAgent's orchestrator fleet mount (run.ts), but
316+
// reuse the existing TUI/exec session store — do not allocate a private
317+
// store only for these verbs. spawnAllowlist stays unwired on primary.
318+
if (sa.sessions !== undefined) {
319+
const fleetSessions = sa.sessions;
320+
const fleetRecords = createFleetRecords();
321+
const fleetDeps = {
322+
permissionGate,
323+
inheritMcpTools: () => inheritedMcpTools,
324+
...(shellTimeout !== undefined ? { shellTimeout } : {}),
325+
...(shellEnv !== undefined ? { shellEnv } : {}),
326+
...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}),
327+
cwd,
328+
getWorkdirBase: sa.getWorkdirBase,
329+
provider: sa.provider,
330+
...(args.getBlobReader !== undefined ? { getBlobReader: args.getBlobReader } : {}),
331+
run: runSubAgent,
332+
sessions: fleetSessions,
333+
fleetRecords,
334+
...(sa.onEvent !== undefined ? { onEvent: sa.onEvent } : {}),
335+
...(sa.onProgress !== undefined ? { onProgress: sa.onProgress } : {}),
336+
...(sa.settings !== undefined ? { settings: sa.settings } : {}),
337+
...(sa.catalog !== undefined ? { catalog: sa.catalog } : {}),
338+
...(args.telemetry !== undefined ? { telemetry: args.telemetry } : {}),
339+
};
340+
orchestratorTools.push(
341+
createSpawnAgentTool(fleetDeps),
342+
createWaitAgentsTool({ sessions: fleetSessions, fleetRecords }),
343+
createCloseAgentTool({ sessions: fleetSessions }),
344+
createResumeAgentTool({ sessions: fleetSessions }),
345+
createInterruptAgentTool({ sessions: fleetSessions }),
346+
createFollowupTaskTool({ sessions: fleetSessions }),
347+
);
348+
}
349+
}
350+
266351
const baseTools: AgentTool[] = [
267352
...fromToolRunner(posixTools).map((tool) => ({
268353
...tool,
@@ -276,47 +361,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
276361
createUseSkillTool(cwd, skillDirs, args.telemetry),
277362
createWebFetchTool(),
278363
createWebSearchTool(),
279-
...(subAgentsEnabled && args.subAgent !== undefined
280-
? [
281-
createTaskTool({
282-
cwd,
283-
getWorkdirBase: args.subAgent.getWorkdirBase,
284-
provider: args.subAgent.provider,
285-
permissionGate,
286-
inheritMcpTools: () => inheritedMcpTools,
287-
run: runSubAgent,
288-
...(shellTimeout !== undefined ? { shellTimeout } : {}),
289-
...(shellEnv !== undefined ? { shellEnv } : {}),
290-
...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}),
291-
...(args.subAgent.onEvent !== undefined ? { onEvent: args.subAgent.onEvent } : {}),
292-
...(args.subAgent.onProgress !== undefined
293-
? { onProgress: args.subAgent.onProgress }
294-
: {}),
295-
...(args.subAgent.sessions !== undefined ? { sessions: args.subAgent.sessions } : {}),
296-
...(args.subAgent.settings !== undefined ? { settings: args.subAgent.settings } : {}),
297-
...(args.subAgent.catalog !== undefined ? { catalog: args.subAgent.catalog } : {}),
298-
...(args.subAgent.profiles !== undefined ? { profiles: args.subAgent.profiles } : {}),
299-
...(args.getBlobReader !== undefined ? { getBlobReader: args.getBlobReader } : {}),
300-
...(args.subAgent.useWorktree !== undefined
301-
? { useWorktree: args.subAgent.useWorktree }
302-
: {}),
303-
...(args.telemetry !== undefined ? { telemetry: args.telemetry } : {}),
304-
}),
305-
...(args.subAgent.profiles !== undefined
306-
? [
307-
createSearchAgentsTool(() => {
308-
const profiles = args.subAgent!.profiles;
309-
return typeof profiles === "function" ? profiles() : (profiles ?? []);
310-
}),
311-
]
312-
: []),
313-
// Tier 1: the primary session is always an orchestrator and may
314-
// target any worker (assertCanTargetAgent's rule), so no authority
315-
// context is passed here — omitting it is treated as unrestricted,
316-
// matching Tier 1's actual authority.
317-
createReadAgentTraceTool(args.subAgent.getWorkdirBase),
318-
]
319-
: []),
364+
...orchestratorTools,
320365
stringTool({
321366
definition: manageTasksDefinition,
322367
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {

0 commit comments

Comments
 (0)