From df0d855c06a021c0e75dc6a52206fc29652aeb45 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 07:26:51 -0700 Subject: [PATCH 1/2] Add list_agents for this caller fleet Parents reconstruct the fleet from memory or by waiting, then re-wait the same ids after a timeout. list_agents reports this install's spawn_agent mailbox without blocking and without listing siblings. --- src/agent/directors/skywalker/package.ts | 2 +- src/agent/fleet-verbs-mount.test.ts | 1 + src/agent/tool-search.test.ts | 4 +- src/agent/tool-search.ts | 2 + src/agent/tools.ts | 2 + src/subagent/agent-fleet.test.ts | 48 ++++++++++++++++++++++++ src/subagent/agent-fleet.ts | 41 ++++++++++++++++++++ src/subagent/authority.test.ts | 6 +-- src/subagent/authority.ts | 13 +++---- src/subagent/run.ts | 9 ++++- 10 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 8c0668a00..76ed7747c 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -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) diff --git a/src/agent/fleet-verbs-mount.test.ts b/src/agent/fleet-verbs-mount.test.ts index ac53419b7..f4ce835b0 100644 --- a/src/agent/fleet-verbs-mount.test.ts +++ b/src/agent/fleet-verbs-mount.test.ts @@ -13,6 +13,7 @@ import { createSubAgentSessionStore } from "../subagent/session-store.js"; const FLEET_VERBS = [ "spawn_agent", "wait_agents", + "list_agents", "close_agent", "resume_agent", "interrupt_agent", diff --git a/src/agent/tool-search.test.ts b/src/agent/tool-search.test.ts index f70662fb2..26c0a0b46 100644 --- a/src/agent/tool-search.test.ts +++ b/src/agent/tool-search.test.ts @@ -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", @@ -237,6 +238,7 @@ describe("advertisedTools", () => { for (const name of [ "spawn_agent", "wait_agents", + "list_agents", "close_agent", "resume_agent", "interrupt_agent", diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index 4d58ca801..0c5f873f2 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -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", @@ -56,6 +57,7 @@ const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = [ "task", "spawn_agent", "wait_agents", + "list_agents", "close_agent", "resume_agent", "interrupt_agent", diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 9afcdfde7..7c5f5e605 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -43,6 +43,7 @@ import { createFleetRecords, createSpawnAgentTool, createWaitAgentsTool, + createListAgentsTool, } from "../subagent/agent-fleet.js"; import { createCloseAgentTool, @@ -344,6 +345,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise { expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted"); }); }); + +describe("list_agents", () => { + test("lists this fleet only, including director and lifecycle", async () => { + const gate = deferred(); + 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" }); + }); +}); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 4c7bc8e9c..a3436ed87 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -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()] @@ -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 => { + 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 })); + }, + }); +} diff --git a/src/subagent/authority.test.ts b/src/subagent/authority.test.ts index 77529dbe9..01cc486d5 100644 --- a/src/subagent/authority.test.ts +++ b/src/subagent/authority.test.ts @@ -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", () => { diff --git a/src/subagent/authority.ts b/src/subagent/authority.ts index 10bfe9e25..59d0ec433 100644 --- a/src/subagent/authority.ts +++ b/src/subagent/authority.ts @@ -7,10 +7,10 @@ * - assertTierMayMountFleetVerb: a Tier 3 leaf may never mount a fleet verb * (task, spawn_agent, wait_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. + * list_agents, 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 @@ -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", @@ -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); diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 735684054..76292dd4a 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -107,7 +107,12 @@ import { DEFAULT_CLOSE_DEADLINE_MS, } from "./dispose.js"; import { createTaskTool } from "./task-tool.js"; -import { createFleetRecords, createSpawnAgentTool, createWaitAgentsTool } from "./agent-fleet.js"; +import { + createFleetRecords, + createSpawnAgentTool, + createWaitAgentsTool, + createListAgentsTool, +} from "./agent-fleet.js"; import { createCloseAgentTool, createResumeAgentTool, @@ -500,6 +505,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise Date: Tue, 25 Aug 2026 08:26:45 -0700 Subject: [PATCH 2/2] Document list_agents as a mounted mailbox fleet verb ARCHITECTURE and authority still called list_agents reserved after it shipped. Nested orchestrators may mount it because it lists this install's spawn mailbox, not the director catalog. --- docs/ARCHITECTURE.md | 6 ++--- src/agent/fleet-verbs-mount.test.ts | 4 +-- src/subagent/authority.ts | 4 +-- src/subagent/run-authority.test.ts | 42 +++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a833d13fe..e14f597af 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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) @@ -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/`) diff --git a/src/agent/fleet-verbs-mount.test.ts b/src/agent/fleet-verbs-mount.test.ts index f4ce835b0..db0333c6f 100644 --- a/src/agent/fleet-verbs-mount.test.ts +++ b/src/agent/fleet-verbs-mount.test.ts @@ -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. */ @@ -21,7 +21,7 @@ const FLEET_VERBS = [ ] 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 = { diff --git a/src/subagent/authority.ts b/src/subagent/authority.ts index 59d0ec433..032f11dfd 100644 --- a/src/subagent/authority.ts +++ b/src/subagent/authority.ts @@ -5,9 +5,9 @@ * 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* of the director catalog + * 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. diff --git a/src/subagent/run-authority.test.ts b/src/subagent/run-authority.test.ts index 182067e5d..5b7ec2adf 100644 --- a/src/subagent/run-authority.test.ts +++ b/src/subagent/run-authority.test.ts @@ -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); + }); +});