Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/agent/fleet-verbs-mount.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
25 changes: 25 additions & 0 deletions src/agent/tool-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 20 additions & 1 deletion src/agent/tool-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
131 changes: 90 additions & 41 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -263,6 +274,84 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT

// Align the advertised run_shell timeout with shell-guard (no built-in default;
// advertise settings.shell.timeoutMs when set).
// Orchestrator tools (task / search / trace / fleet) are assembled once so the
// fleet verbs can reuse the same sessions store the task tool already holds —
// never a private mailbox allocated only for spawn_agent/wait_agents.
const orchestratorTools: AgentTool[] = [];
if (subAgentsEnabled && args.subAgent !== undefined) {
const sa = args.subAgent;
orchestratorTools.push(
createTaskTool({
cwd,
getWorkdirBase: sa.getWorkdirBase,
provider: sa.provider,
permissionGate,
inheritMcpTools: () => 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,
Expand All @@ -276,47 +365,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
createUseSkillTool(cwd, skillDirs, args.telemetry),
createWebFetchTool(),
createWebSearchTool(),
...(subAgentsEnabled && args.subAgent !== undefined
? [
createTaskTool({
cwd,
getWorkdirBase: args.subAgent.getWorkdirBase,
provider: args.subAgent.provider,
permissionGate,
inheritMcpTools: () => 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<string, unknown>): Promise<string> => {
Expand Down
Loading