diff --git a/src/agent/fleet-verbs-mount.test.ts b/src/agent/fleet-verbs-mount.test.ts new file mode 100644 index 000000000..ac53419b7 --- /dev/null +++ b/src/agent/fleet-verbs-mount.test.ts @@ -0,0 +1,75 @@ +/** + * Primary createAgentToolset mounts the six fleet verbs beside task / + * search_agents / read_agent_trace when subAgent (with the shared TUI + * sessions store) is wired. Leaves / no-subAgent toolsets stay without them. + */ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; + +import { createSubAgentSessionStore } from "../subagent/session-store.js"; + +const FLEET_VERBS = [ + "spawn_agent", + "wait_agents", + "close_agent", + "resume_agent", + "interrupt_agent", + "followup_task", +] as const; + +describe("primary fleet verb mount", () => { + test("createAgentToolset registers the six fleet verbs when subAgent + sessions are set", async () => { + const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-")); + const { createAgentToolset } = await import("./tools.js"); + const permissionGate = { + check: async () => ({ allowed: true }), + getSkipPermissions: () => false, + } as never; + const sessions = createSubAgentSessionStore(); + + const toolset = await createAgentToolset({ + cwd, + permissionGate, + onOperatorGate: async () => ({ kind: "option", index: 0 }), + subAgent: { + provider: { + providerName: "test", + baseURL: "http://127.0.0.1:0", + model: "test-model", + }, + getWorkdirBase: () => cwd, + sessions, + }, + }); + const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name); + expect(names).toContain("task"); + expect(names).toContain("read_agent_trace"); + for (const name of FLEET_VERBS) { + expect(names).toContain(name); + } + await toolset.dispose(); + }); + + test("createAgentToolset omits fleet verbs when subAgent is not set", async () => { + const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-")); + const { createAgentToolset } = await import("./tools.js"); + const permissionGate = { + check: async () => ({ allowed: true }), + getSkipPermissions: () => false, + } as never; + + const toolset = await createAgentToolset({ + cwd, + permissionGate, + onOperatorGate: async () => ({ kind: "option", index: 0 }), + }); + const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name); + expect(names).not.toContain("task"); + for (const name of FLEET_VERBS) { + expect(names).not.toContain(name); + } + await toolset.dispose(); + }); +}); diff --git a/src/agent/tool-search.test.ts b/src/agent/tool-search.test.ts index 3368ed6c3..f70662fb2 100644 --- a/src/agent/tool-search.test.ts +++ b/src/agent/tool-search.test.ts @@ -83,6 +83,21 @@ describe("createToolIndex", () => { ); }); + test("orchestrator mode advertises the six fleet verbs", () => { + const advertised = advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY); + for (const name of [ + "spawn_agent", + "wait_agents", + "close_agent", + "resume_agent", + "interrupt_agent", + "followup_task", + ] as const) { + expect(CORE_TOOL_NAMES).toContain(name); + expect(advertised).toContain(name); + } + }); + test("manage_tasks is advertised regardless of availability", () => { expect(coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY)).toContain("manage_tasks"); }); @@ -219,6 +234,16 @@ describe("advertisedTools", () => { const prefix = advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY); expect(prefix).toContain("task"); expect(prefix).toContain("search_agents"); + for (const name of [ + "spawn_agent", + "wait_agents", + "close_agent", + "resume_agent", + "interrupt_agent", + "followup_task", + ] as const) { + expect(prefix).toContain(name); + } // advertisedTools only emits tools present in the registry; multi-agent // tools appear on the wire when createAgentToolset registers them. const names = advertisedTools(registry, [], prefix).map((d) => d.name); diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index eda40ea59..4d58ca801 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -39,9 +39,28 @@ export const CORE_TOOL_NAMES: readonly string[] = [ // round-trip. Catalog-only placement left the model discovering profiles then // failing on an unloaded task tool. "task", + // Fleet verbs (non-blocking spawn + lifecycle). Mounted on primary when + // subAgent is wired; advertised here so the model does not tool_search for + // them. Package allowlists (ORCHESTRATOR_TOOLS / SKYWALKER_TOOLS) are a + // separate, deferred change. + "spawn_agent", + "wait_agents", + "close_agent", + "resume_agent", + "interrupt_agent", + "followup_task", ]; -const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = ["search_agents", "task"]; +const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = [ + "search_agents", + "task", + "spawn_agent", + "wait_agents", + "close_agent", + "resume_agent", + "interrupt_agent", + "followup_task", +]; // Session-start facts that gate a core tool's advertisement. Each must be // knowable once, before the first inference call, and must never change for diff --git a/src/agent/tools.ts b/src/agent/tools.ts index cf6fcf5ab..23a46b14b 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -39,6 +39,17 @@ import { type SubAgentProvider, type SubAgentSessionStore, } from "../subagent/index.js"; +import { + createFleetRecords, + createSpawnAgentTool, + createWaitAgentsTool, +} from "../subagent/agent-fleet.js"; +import { + createCloseAgentTool, + createResumeAgentTool, + createInterruptAgentTool, + createFollowupTaskTool, +} from "../subagent/lifecycle-tools.js"; import { parseManageTasksArgs } from "./tasks.js"; import { createListDirTool } from "../util/list-dir.js"; import { createWebFetchTool } from "../tools/web-fetch.js"; @@ -263,6 +274,84 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise inheritedMcpTools, + run: runSubAgent, + ...(shellTimeout !== undefined ? { shellTimeout } : {}), + ...(shellEnv !== undefined ? { shellEnv } : {}), + ...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}), + ...(sa.onEvent !== undefined ? { onEvent: sa.onEvent } : {}), + ...(sa.onProgress !== undefined ? { onProgress: sa.onProgress } : {}), + ...(sa.sessions !== undefined ? { sessions: sa.sessions } : {}), + ...(sa.settings !== undefined ? { settings: sa.settings } : {}), + ...(sa.catalog !== undefined ? { catalog: sa.catalog } : {}), + ...(sa.profiles !== undefined ? { profiles: sa.profiles } : {}), + ...(args.getBlobReader !== undefined ? { getBlobReader: args.getBlobReader } : {}), + ...(sa.useWorktree !== undefined ? { useWorktree: sa.useWorktree } : {}), + ...(args.telemetry !== undefined ? { telemetry: args.telemetry } : {}), + }), + ); + if (sa.profiles !== undefined) { + orchestratorTools.push( + createSearchAgentsTool(() => { + const profiles = sa.profiles; + return typeof profiles === "function" ? profiles() : (profiles ?? []); + }), + ); + } + // Tier 1: the primary session is always an orchestrator and may + // target any worker (assertCanTargetAgent's rule), so no authority + // context is passed here — omitting it is treated as unrestricted, + // matching Tier 1's actual authority. + orchestratorTools.push(createReadAgentTraceTool(sa.getWorkdirBase)); + + // Mirror nested runSubAgent's orchestrator fleet mount (run.ts), but + // reuse the existing TUI/exec session store — do not allocate a private + // store only for these verbs. spawnAllowlist stays unwired on primary. + if (sa.sessions !== undefined) { + const fleetSessions = sa.sessions; + const fleetRecords = createFleetRecords(); + const fleetDeps = { + permissionGate, + inheritMcpTools: () => inheritedMcpTools, + ...(shellTimeout !== undefined ? { shellTimeout } : {}), + ...(shellEnv !== undefined ? { shellEnv } : {}), + ...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}), + cwd, + getWorkdirBase: sa.getWorkdirBase, + provider: sa.provider, + ...(args.getBlobReader !== undefined ? { getBlobReader: args.getBlobReader } : {}), + run: runSubAgent, + sessions: fleetSessions, + fleetRecords, + ...(sa.onEvent !== undefined ? { onEvent: sa.onEvent } : {}), + ...(sa.onProgress !== undefined ? { onProgress: sa.onProgress } : {}), + ...(sa.settings !== undefined ? { settings: sa.settings } : {}), + ...(sa.catalog !== undefined ? { catalog: sa.catalog } : {}), + ...(args.telemetry !== undefined ? { telemetry: args.telemetry } : {}), + }; + orchestratorTools.push( + createSpawnAgentTool(fleetDeps), + createWaitAgentsTool({ sessions: fleetSessions, fleetRecords }), + createCloseAgentTool({ sessions: fleetSessions }), + createResumeAgentTool({ sessions: fleetSessions }), + createInterruptAgentTool({ sessions: fleetSessions }), + createFollowupTaskTool({ sessions: fleetSessions }), + ); + } + } + const baseTools: AgentTool[] = [ ...fromToolRunner(posixTools).map((tool) => ({ ...tool, @@ -276,47 +365,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise inheritedMcpTools, - run: runSubAgent, - ...(shellTimeout !== undefined ? { shellTimeout } : {}), - ...(shellEnv !== undefined ? { shellEnv } : {}), - ...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}), - ...(args.subAgent.onEvent !== undefined ? { onEvent: args.subAgent.onEvent } : {}), - ...(args.subAgent.onProgress !== undefined - ? { onProgress: args.subAgent.onProgress } - : {}), - ...(args.subAgent.sessions !== undefined ? { sessions: args.subAgent.sessions } : {}), - ...(args.subAgent.settings !== undefined ? { settings: args.subAgent.settings } : {}), - ...(args.subAgent.catalog !== undefined ? { catalog: args.subAgent.catalog } : {}), - ...(args.subAgent.profiles !== undefined ? { profiles: args.subAgent.profiles } : {}), - ...(args.getBlobReader !== undefined ? { getBlobReader: args.getBlobReader } : {}), - ...(args.subAgent.useWorktree !== undefined - ? { useWorktree: args.subAgent.useWorktree } - : {}), - ...(args.telemetry !== undefined ? { telemetry: args.telemetry } : {}), - }), - ...(args.subAgent.profiles !== undefined - ? [ - createSearchAgentsTool(() => { - const profiles = args.subAgent!.profiles; - return typeof profiles === "function" ? profiles() : (profiles ?? []); - }), - ] - : []), - // Tier 1: the primary session is always an orchestrator and may - // target any worker (assertCanTargetAgent's rule), so no authority - // context is passed here — omitting it is treated as unrestricted, - // matching Tier 1's actual authority. - createReadAgentTraceTool(args.subAgent.getWorkdirBase), - ] - : []), + ...orchestratorTools, stringTool({ definition: manageTasksDefinition, handler: async (rawArgs: Record): Promise => {