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
6 changes: 3 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ The **`task`** tool **spawns a sub-agent** on a separate inference source (tier/

When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** plugins, including **data-only** markdown plugins with no `index.ts`), the chat model also receives **`search_agents`** — a lexical index over profile id, description, and role text so the model can discover ids before calling `task(agent=...)`. Results include each match's full loaded system prompt / body so the parent can inspect plugin or Claude marketplace agents without `read_file` on paths outside the session cwd (path-escape blocks those roots by design; writes remain blocked). `task` and `search_agents` are core tools on the primary session.

Profiles with `orchestrator: true` may themselves call `task` (one hop only): nested dispatch installs `task` + `search_agents` with `allowOrchestrator: false` so the tree bottoms out. Unknown `agent` ids fail closed.
Profiles with `orchestrator: true` may themselves call `task` (one hop only): nested dispatch installs `task` and the mailbox-scoped fleet verbs (`spawn_agent`, `wait_agents`, `list_agents`, …) with `allowOrchestrator: false` so the tree bottoms out. Fleet discovery (`search_agents`) stays Tier 1 only. Unknown `agent` ids fail closed.

#### Fleet authority tiers (`src/subagent/authority.ts`) (CL-6941)

Expand All @@ -219,8 +219,8 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent

Enforcement is runtime code at the existing tool-mount point, not prompt wording — this is the fix for four prior mechanisms (`writePaths`, `report.requiredSections`, a `--config` comment, the thrash matcher) that were documented-as-enforced while enforcing nothing:

- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator. `FLEET_VERBS` in `authority.ts` names the live verbs (`task`, `spawn_agent`, `wait_agents`, `interrupt_agent`, `close_agent`, `resume_agent`, `followup_task`, `read_agent_trace`, `search_agents`) plus reserved names (`list_agents`, `send_input`) so a later mount site inherits the same gate.
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. `read_agent_trace` is a production call site. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's own `fleetRecords`, not every running session in the shared store. `interrupt_agent` and `close_agent` terminalize the wait mailbox immediately.
- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator. `FLEET_VERBS` in `authority.ts` names the live verbs (`task`, `spawn_agent`, `wait_agents`, `list_agents`, `interrupt_agent`, `close_agent`, `resume_agent`, `followup_task`, `read_agent_trace`, `search_agents`) plus reserved name (`send_input`) so a later mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only.
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. `read_agent_trace` is a production call site. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's own `fleetRecords`, not every running session in the shared store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` and `close_agent` terminalize the wait mailbox immediately.
- `task()` remains the deprecated fused spawn+wait fallback. `spawn_agent` + `wait_agents` is the supported parallel path. The tier check still gates which packages may mount any fleet verb.

#### Closed director fleet (`src/agent/directors/`)
Expand Down
2 changes: 1 addition & 1 deletion src/agent/directors/skywalker/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ You do not do the specialists' jobs by default. For tiny bounded product edits,

Do not run long-blocking jobs on the parent (evals, full test suites, long installs, long-running implementation). Dispatch intern (mechanical shell), tester (suite / repro), or builder (substantial code). Path tools (write_file/edit_file/delete_file) are the DIY surface; shell file-writes stay denied.

Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns immediately with an agent_id and does not hold the parent. Then **reply to the operator** with who is running and what happens next before you block. Prefer ending that turn (or calling wait_agents with a short timeout_ms) so Enter can land; do not immediately fuse into a long wait_agents / task() right after spawn. wait_agents later on the targets you need (or omit targets to wait on this session's own uncollected spawns — never a sibling's). Use mode="all" when you need every target to finish; interrupt_agent unblocks wait_agents immediately. A timeout means still running — do not tight-loop wait_agents hoping for a different answer. task() still fuses spawn+wait and holds the parent until that one worker finishes. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting wait_agents / task() holds those steers. A bare spawn_agent does not.
Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns immediately with an agent_id and does not hold the parent. Then **reply to the operator** with who is running and what happens next before you block. Prefer ending that turn (or calling wait_agents with a short timeout_ms) so Enter can land; do not immediately fuse into a long wait_agents / task() right after spawn. wait_agents later on the targets you need (or omit targets to wait on this session's own uncollected spawns — never a sibling's). list_agents shows that same fleet without blocking. Use mode="all" when you need every target to finish; interrupt_agent unblocks wait_agents immediately. A timeout means still running — do not tight-loop wait_agents hoping for a different answer. task() still fuses spawn+wait and holds the parent until that one worker finishes. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting wait_agents / task() holds those steers. A bare spawn_agent does not.

# Operator updates (mandatory while fleet is live)

Expand Down
5 changes: 3 additions & 2 deletions src/agent/fleet-verbs-mount.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Primary createAgentToolset mounts the six fleet verbs beside task /
* Primary createAgentToolset mounts the seven 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.
*/
Expand All @@ -13,14 +13,15 @@ import { createSubAgentSessionStore } from "../subagent/session-store.js";
const FLEET_VERBS = [
"spawn_agent",
"wait_agents",
"list_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 () => {
test("createAgentToolset registers the seven fleet verbs when subAgent + sessions are set", async () => {
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
const { createAgentToolset } = await import("./tools.js");
const permissionGate = {
Expand Down
4 changes: 3 additions & 1 deletion src/agent/tool-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,12 @@ describe("createToolIndex", () => {
);
});

test("orchestrator mode advertises the six fleet verbs", () => {
test("orchestrator mode advertises the fleet verbs", () => {
const advertised = advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY);
for (const name of [
"spawn_agent",
"wait_agents",
"list_agents",
"close_agent",
"resume_agent",
"interrupt_agent",
Expand Down Expand Up @@ -237,6 +238,7 @@ describe("advertisedTools", () => {
for (const name of [
"spawn_agent",
"wait_agents",
"list_agents",
"close_agent",
"resume_agent",
"interrupt_agent",
Expand Down
2 changes: 2 additions & 0 deletions src/agent/tool-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const CORE_TOOL_NAMES: readonly string[] = [
// separate, deferred change.
"spawn_agent",
"wait_agents",
"list_agents",
"close_agent",
"resume_agent",
"interrupt_agent",
Expand All @@ -56,6 +57,7 @@ const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = [
"task",
"spawn_agent",
"wait_agents",
"list_agents",
"close_agent",
"resume_agent",
"interrupt_agent",
Expand Down
2 changes: 2 additions & 0 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
createFleetRecords,
createSpawnAgentTool,
createWaitAgentsTool,
createListAgentsTool,
} from "../subagent/agent-fleet.js";
import {
createCloseAgentTool,
Expand Down Expand Up @@ -344,6 +345,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
orchestratorTools.push(
createSpawnAgentTool(fleetDeps),
createWaitAgentsTool({ sessions: fleetSessions, fleetRecords }),
createListAgentsTool({ sessions: fleetSessions, fleetRecords }),
createCloseAgentTool({ sessions: fleetSessions, fleetRecords }),
createResumeAgentTool({ sessions: fleetSessions }),
createInterruptAgentTool({ sessions: fleetSessions, fleetRecords }),
Expand Down
48 changes: 48 additions & 0 deletions src/subagent/agent-fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createFleetRecords,
createSpawnAgentTool,
createWaitAgentsTool,
createListAgentsTool,
MAX_FLEET_RECORDS,
type AgentFleetDeps,
} from "./agent-fleet.js";
Expand Down Expand Up @@ -766,3 +767,50 @@ describe("close_agent unblocks wait_agents", () => {
expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted");
});
});

describe("list_agents", () => {
test("lists this fleet only, including director and lifecycle", async () => {
const gate = deferred<RunSubAgentResult>();
const deps = makeDeps(async () => gate.promise);
deps.sessions.start({
id: "foreign-sibling",
description: "someone else's worker",
agentId: "explorer",
brief: "b",
});
const spawn = createSpawnAgentTool(deps);
const list = createListAgentsTool({
sessions: deps.sessions,
fleetRecords: deps.fleetRecords,
});
const spawned = await callTool(spawn, {
description: "mine",
prompt: "do it",
intent: "explore",
});
if (list.kind !== "full") throw new Error("expected full tool");
const raw = await list.handler(
{ id: "list-1", name: "list_agents", arguments: {} },
new AbortController().signal,
);
const content = typeof raw.content === "string" ? raw.content : JSON.stringify(raw.content);
const parsed = JSON.parse(content) as {
agents: {
agent_id: string;
status: string;
collected: boolean;
director?: string;
description?: string;
lifecycle?: string;
}[];
};
expect(parsed.agents).toHaveLength(1);
expect(parsed.agents[0]!.agent_id).toBe(spawned.agent_id as string);
expect(parsed.agents[0]!.status).toBe("running");
expect(parsed.agents[0]!.collected).toBe(false);
expect(parsed.agents[0]!.director).toBe("explorer");
expect(parsed.agents[0]!.description).toBe("mine");
expect(parsed.agents[0]!.lifecycle).toBe("pending_init");
gate.resolve({ report: "done" });
});
});
41 changes: 41 additions & 0 deletions src/subagent/agent-fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ class FleetRecords {
this.notify();
}

ids(): string[] {
return [...this.records.keys()];
}

/** Running plus terminal-but-not-yet-handed-to-a-waiter. */
uncollectedIds(): string[] {
return [...this.records.entries()]
Expand Down Expand Up @@ -726,3 +730,40 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool {
},
});
}

export const listAgentsToolDefinition: ToolDefinition = {
name: "list_agents",
description:
"List the workers this session started with spawn_agent — the same fleet wait_agents " +
"collects. Does not list siblings or another orchestrator's workers. Each entry is id, " +
"director, description, wait status, lifecycle, and whether wait_agents already collected it.",
inputSchema: {
type: "object",
properties: {},
},
};

export function createListAgentsTool(deps: WaitAgentsDeps): AgentTool {
return tool({
definition: listAgentsToolDefinition,
handler: async (call, _signal): Promise<ToolResult> => {
const agents = deps.fleetRecords.ids().map((id) => {
const record = deps.fleetRecords.peek(id);
const session = deps.sessions.get(id);
return {
agent_id: id,
status: record?.status ?? "unknown",
collected: record?.collected === true,
...(session !== undefined
? {
director: session.agentId,
description: session.description,
lifecycle: session.lifecycleStatus,
}
: {}),
};
});
return fleetResult(call.id, JSON.stringify({ agents }));
},
});
}
6 changes: 2 additions & 4 deletions src/subagent/authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,11 @@ describe("assertTierMayMountFleetVerb", () => {

// CL-7051: fleet discovery is Skywalker (Tier 1) only — nested directors keep
// task/spawn allowlists but must not discover the full fleet.
test("Tier 2 nested orchestrator cannot mount search_agents or list_agents", () => {
test("Tier 2 nested orchestrator cannot mount search_agents but may list its own fleet", () => {
expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "search_agents")).toThrow(
FleetAuthorityError,
);
expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "list_agents")).toThrow(
FleetAuthorityError,
);
expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "list_agents")).not.toThrow();
});

test("Tier 1 orchestrator may mount search_agents and list_agents", () => {
Expand Down
15 changes: 7 additions & 8 deletions src/subagent/authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
* in a prompt. This module owns two checks:
*
* - assertTierMayMountFleetVerb: a Tier 3 leaf may never mount a fleet verb
* (task, spawn_agent, wait_agents, interrupt_agent, close_agent,
* (task, spawn_agent, wait_agents, list_agents, interrupt_agent, close_agent,
* resume_agent, followup_task, read_agent_trace, search_agents; reserved:
* list_agents, send_input). Fleet *discovery* verbs (search_agents,
* list_agents) are further restricted to Tier 1 only (CL-7051) — nested
* orchestrators keep task/spawn allowlists but must not discover the
* full fleet.
* send_input). Fleet *discovery* of the director catalog
* (search_agents) is Tier 1 only (CL-7051). list_agents is not catalog
* discovery — it lists this install's own spawn_agent workers, the same
* scoped mailbox wait_agents uses, so nested orchestrators may mount it.
* - assertCanTargetAgent: a Tier 2 nested orchestrator may act only on its
* own descendants, never a sibling or anything above it in the tree.
* Tier 1 (the primary orchestrator) may target anyone. Callers pass the
Expand All @@ -26,8 +26,7 @@ export type { SubagentTier } from "../agent/directors/types.js";
/**
* Every tool that grants control over other agents (spawn, list, steer,
* observe). Tier 3 leaves may mount none of these — ever. Reserved names
* (`list_agents`, `send_input`) stay in the set so a later mount site
* inherits the gate instead of needing a second allowlist.
* `send_input` stays reserved so a later mount site inherits the gate.
*/
export const FLEET_VERBS = new Set([
"task",
Expand All @@ -47,7 +46,7 @@ export const FLEET_VERBS = new Set([
* Fleet discovery — Tier 1 (skywalker) only. Nested orchestrators spawn from
* a closed allowlist and must not index the full fleet (CL-7051).
*/
export const ORCHESTRATOR_ONLY_FLEET_VERBS = new Set(["search_agents", "list_agents"]);
export const ORCHESTRATOR_ONLY_FLEET_VERBS = new Set(["search_agents"]);

export function isFleetVerb(toolName: string): boolean {
return FLEET_VERBS.has(toolName);
Expand Down
42 changes: 42 additions & 0 deletions src/subagent/run-authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,45 @@ describe("runSubAgent passes parentSessionId into spawn_agent mount", () => {
expect(capturedParentSessionId).toBe("greybeard-session");
});
});

describe("runSubAgent list_agents mount (mailbox-scoped, nested ok)", () => {
test("nested-orchestrator mounts list_agents", async () => {
const cwd = await tmpCwd();
let listAgentsMounts = 0;

await withMockedModuleDuring(
import.meta.resolve("./agent-fleet.js"),
(real: typeof import("./agent-fleet.js")) => ({
...real,
createListAgentsTool: (deps: never) => {
listAgentsMounts++;
return real.createListAgentsTool(deps);
},
}),
async () => {
const { runSubAgent: run } = await import("./run.js");
try {
await run({
...baseParams(cwd, join(cwd, ".ctx")),
id: "greybeard-session",
orchestrator: true,
orchestratorTier: "nested-orchestrator",
nestedDispatch: {
permissionGate: testPermissionGate,
getWorkdirBase: () => join(cwd, ".ctx"),
provider: {
providerName: "test",
baseURL: "http://localhost",
model: "test-model",
},
},
});
} catch {
// Inference/agent construction may fail; mount decisions run first.
}
},
);

expect(listAgentsMounts).toBe(1);
});
});
Loading
Loading