Skip to content

Commit fdcfafb

Browse files
Mount fleet verbs on primary Skywalker (#635)
* 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 * Fix prettier formatting in Skywalker tool mounts
1 parent efa87d9 commit fdcfafb

4 files changed

Lines changed: 210 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: 90 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ import {
3939
type SubAgentProvider,
4040
type SubAgentSessionStore,
4141
} from "../subagent/index.js";
42+
import {
43+
createFleetRecords,
44+
createSpawnAgentTool,
45+
createWaitAgentsTool,
46+
} from "../subagent/agent-fleet.js";
47+
import {
48+
createCloseAgentTool,
49+
createResumeAgentTool,
50+
createInterruptAgentTool,
51+
createFollowupTaskTool,
52+
} from "../subagent/lifecycle-tools.js";
4253
import { parseManageTasksArgs } from "./tasks.js";
4354
import { createListDirTool } from "../util/list-dir.js";
4455
import { createWebFetchTool } from "../tools/web-fetch.js";
@@ -263,6 +274,84 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
263274

264275
// Align the advertised run_shell timeout with shell-guard (no built-in default;
265276
// advertise settings.shell.timeoutMs when set).
277+
// Orchestrator tools (task / search / trace / fleet) are assembled once so the
278+
// fleet verbs can reuse the same sessions store the task tool already holds —
279+
// never a private mailbox allocated only for spawn_agent/wait_agents.
280+
const orchestratorTools: AgentTool[] = [];
281+
if (subAgentsEnabled && args.subAgent !== undefined) {
282+
const sa = args.subAgent;
283+
orchestratorTools.push(
284+
createTaskTool({
285+
cwd,
286+
getWorkdirBase: sa.getWorkdirBase,
287+
provider: sa.provider,
288+
permissionGate,
289+
inheritMcpTools: () => inheritedMcpTools,
290+
run: runSubAgent,
291+
...(shellTimeout !== undefined ? { shellTimeout } : {}),
292+
...(shellEnv !== undefined ? { shellEnv } : {}),
293+
...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}),
294+
...(sa.onEvent !== undefined ? { onEvent: sa.onEvent } : {}),
295+
...(sa.onProgress !== undefined ? { onProgress: sa.onProgress } : {}),
296+
...(sa.sessions !== undefined ? { sessions: sa.sessions } : {}),
297+
...(sa.settings !== undefined ? { settings: sa.settings } : {}),
298+
...(sa.catalog !== undefined ? { catalog: sa.catalog } : {}),
299+
...(sa.profiles !== undefined ? { profiles: sa.profiles } : {}),
300+
...(args.getBlobReader !== undefined ? { getBlobReader: args.getBlobReader } : {}),
301+
...(sa.useWorktree !== undefined ? { useWorktree: sa.useWorktree } : {}),
302+
...(args.telemetry !== undefined ? { telemetry: args.telemetry } : {}),
303+
}),
304+
);
305+
if (sa.profiles !== undefined) {
306+
orchestratorTools.push(
307+
createSearchAgentsTool(() => {
308+
const profiles = sa.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+
orchestratorTools.push(createReadAgentTraceTool(sa.getWorkdirBase));
318+
319+
// Mirror nested runSubAgent's orchestrator fleet mount (run.ts), but
320+
// reuse the existing TUI/exec session store — do not allocate a private
321+
// store only for these verbs. spawnAllowlist stays unwired on primary.
322+
if (sa.sessions !== undefined) {
323+
const fleetSessions = sa.sessions;
324+
const fleetRecords = createFleetRecords();
325+
const fleetDeps = {
326+
permissionGate,
327+
inheritMcpTools: () => inheritedMcpTools,
328+
...(shellTimeout !== undefined ? { shellTimeout } : {}),
329+
...(shellEnv !== undefined ? { shellEnv } : {}),
330+
...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}),
331+
cwd,
332+
getWorkdirBase: sa.getWorkdirBase,
333+
provider: sa.provider,
334+
...(args.getBlobReader !== undefined ? { getBlobReader: args.getBlobReader } : {}),
335+
run: runSubAgent,
336+
sessions: fleetSessions,
337+
fleetRecords,
338+
...(sa.onEvent !== undefined ? { onEvent: sa.onEvent } : {}),
339+
...(sa.onProgress !== undefined ? { onProgress: sa.onProgress } : {}),
340+
...(sa.settings !== undefined ? { settings: sa.settings } : {}),
341+
...(sa.catalog !== undefined ? { catalog: sa.catalog } : {}),
342+
...(args.telemetry !== undefined ? { telemetry: args.telemetry } : {}),
343+
};
344+
orchestratorTools.push(
345+
createSpawnAgentTool(fleetDeps),
346+
createWaitAgentsTool({ sessions: fleetSessions, fleetRecords }),
347+
createCloseAgentTool({ sessions: fleetSessions }),
348+
createResumeAgentTool({ sessions: fleetSessions }),
349+
createInterruptAgentTool({ sessions: fleetSessions }),
350+
createFollowupTaskTool({ sessions: fleetSessions }),
351+
);
352+
}
353+
}
354+
266355
const baseTools: AgentTool[] = [
267356
...fromToolRunner(posixTools).map((tool) => ({
268357
...tool,
@@ -276,47 +365,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
276365
createUseSkillTool(cwd, skillDirs, args.telemetry),
277366
createWebFetchTool(),
278367
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-
: []),
368+
...orchestratorTools,
320369
stringTool({
321370
definition: manageTasksDefinition,
322371
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {

0 commit comments

Comments
 (0)