Skip to content

Commit 204ef15

Browse files
Add list_agents for this caller fleet (#681)
* 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. * 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.
1 parent 9d905ca commit 204ef15

12 files changed

Lines changed: 162 additions & 20 deletions

docs/ARCHITECTURE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ The **`task`** tool **spawns a sub-agent** on a separate inference source (tier/
205205

206206
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.
207207

208-
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.
208+
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.
209209

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

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

220220
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:
221221

222-
- **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.
223-
- **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.
222+
- **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.
223+
- **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.
224224
- `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.
225225

226226
#### Closed director fleet (`src/agent/directors/`)

src/agent/directors/skywalker/package.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ You do not do the specialists' jobs by default. For tiny bounded product edits,
1616
1717
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.
1818
19-
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.
19+
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.
2020
2121
# Operator updates (mandatory while fleet is live)
2222

src/agent/fleet-verbs-mount.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Primary createAgentToolset mounts the six fleet verbs beside task /
2+
* Primary createAgentToolset mounts the seven fleet verbs beside task /
33
* search_agents / read_agent_trace when subAgent (with the shared TUI
44
* sessions store) is wired. Leaves / no-subAgent toolsets stay without them.
55
*/
@@ -13,14 +13,15 @@ import { createSubAgentSessionStore } from "../subagent/session-store.js";
1313
const FLEET_VERBS = [
1414
"spawn_agent",
1515
"wait_agents",
16+
"list_agents",
1617
"close_agent",
1718
"resume_agent",
1819
"interrupt_agent",
1920
"followup_task",
2021
] as const;
2122

2223
describe("primary fleet verb mount", () => {
23-
test("createAgentToolset registers the six fleet verbs when subAgent + sessions are set", async () => {
24+
test("createAgentToolset registers the seven fleet verbs when subAgent + sessions are set", async () => {
2425
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
2526
const { createAgentToolset } = await import("./tools.js");
2627
const permissionGate = {

src/agent/tool-search.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,12 @@ describe("createToolIndex", () => {
8383
);
8484
});
8585

86-
test("orchestrator mode advertises the six fleet verbs", () => {
86+
test("orchestrator mode advertises the fleet verbs", () => {
8787
const advertised = advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY);
8888
for (const name of [
8989
"spawn_agent",
9090
"wait_agents",
91+
"list_agents",
9192
"close_agent",
9293
"resume_agent",
9394
"interrupt_agent",
@@ -237,6 +238,7 @@ describe("advertisedTools", () => {
237238
for (const name of [
238239
"spawn_agent",
239240
"wait_agents",
241+
"list_agents",
240242
"close_agent",
241243
"resume_agent",
242244
"interrupt_agent",

src/agent/tool-search.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const CORE_TOOL_NAMES: readonly string[] = [
4545
// separate, deferred change.
4646
"spawn_agent",
4747
"wait_agents",
48+
"list_agents",
4849
"close_agent",
4950
"resume_agent",
5051
"interrupt_agent",
@@ -56,6 +57,7 @@ const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = [
5657
"task",
5758
"spawn_agent",
5859
"wait_agents",
60+
"list_agents",
5961
"close_agent",
6062
"resume_agent",
6163
"interrupt_agent",

src/agent/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
createFleetRecords,
4444
createSpawnAgentTool,
4545
createWaitAgentsTool,
46+
createListAgentsTool,
4647
} from "../subagent/agent-fleet.js";
4748
import {
4849
createCloseAgentTool,
@@ -344,6 +345,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
344345
orchestratorTools.push(
345346
createSpawnAgentTool(fleetDeps),
346347
createWaitAgentsTool({ sessions: fleetSessions, fleetRecords }),
348+
createListAgentsTool({ sessions: fleetSessions, fleetRecords }),
347349
createCloseAgentTool({ sessions: fleetSessions, fleetRecords }),
348350
createResumeAgentTool({ sessions: fleetSessions }),
349351
createInterruptAgentTool({ sessions: fleetSessions, fleetRecords }),

src/subagent/agent-fleet.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
createFleetRecords,
55
createSpawnAgentTool,
66
createWaitAgentsTool,
7+
createListAgentsTool,
78
MAX_FLEET_RECORDS,
89
type AgentFleetDeps,
910
} from "./agent-fleet.js";
@@ -766,3 +767,50 @@ describe("close_agent unblocks wait_agents", () => {
766767
expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted");
767768
});
768769
});
770+
771+
describe("list_agents", () => {
772+
test("lists this fleet only, including director and lifecycle", async () => {
773+
const gate = deferred<RunSubAgentResult>();
774+
const deps = makeDeps(async () => gate.promise);
775+
deps.sessions.start({
776+
id: "foreign-sibling",
777+
description: "someone else's worker",
778+
agentId: "explorer",
779+
brief: "b",
780+
});
781+
const spawn = createSpawnAgentTool(deps);
782+
const list = createListAgentsTool({
783+
sessions: deps.sessions,
784+
fleetRecords: deps.fleetRecords,
785+
});
786+
const spawned = await callTool(spawn, {
787+
description: "mine",
788+
prompt: "do it",
789+
intent: "explore",
790+
});
791+
if (list.kind !== "full") throw new Error("expected full tool");
792+
const raw = await list.handler(
793+
{ id: "list-1", name: "list_agents", arguments: {} },
794+
new AbortController().signal,
795+
);
796+
const content = typeof raw.content === "string" ? raw.content : JSON.stringify(raw.content);
797+
const parsed = JSON.parse(content) as {
798+
agents: {
799+
agent_id: string;
800+
status: string;
801+
collected: boolean;
802+
director?: string;
803+
description?: string;
804+
lifecycle?: string;
805+
}[];
806+
};
807+
expect(parsed.agents).toHaveLength(1);
808+
expect(parsed.agents[0]!.agent_id).toBe(spawned.agent_id as string);
809+
expect(parsed.agents[0]!.status).toBe("running");
810+
expect(parsed.agents[0]!.collected).toBe(false);
811+
expect(parsed.agents[0]!.director).toBe("explorer");
812+
expect(parsed.agents[0]!.description).toBe("mine");
813+
expect(parsed.agents[0]!.lifecycle).toBe("pending_init");
814+
gate.resolve({ report: "done" });
815+
});
816+
});

src/subagent/agent-fleet.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ class FleetRecords {
157157
this.notify();
158158
}
159159

160+
ids(): string[] {
161+
return [...this.records.keys()];
162+
}
163+
160164
/** Running plus terminal-but-not-yet-handed-to-a-waiter. */
161165
uncollectedIds(): string[] {
162166
return [...this.records.entries()]
@@ -726,3 +730,40 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool {
726730
},
727731
});
728732
}
733+
734+
export const listAgentsToolDefinition: ToolDefinition = {
735+
name: "list_agents",
736+
description:
737+
"List the workers this session started with spawn_agent — the same fleet wait_agents " +
738+
"collects. Does not list siblings or another orchestrator's workers. Each entry is id, " +
739+
"director, description, wait status, lifecycle, and whether wait_agents already collected it.",
740+
inputSchema: {
741+
type: "object",
742+
properties: {},
743+
},
744+
};
745+
746+
export function createListAgentsTool(deps: WaitAgentsDeps): AgentTool {
747+
return tool({
748+
definition: listAgentsToolDefinition,
749+
handler: async (call, _signal): Promise<ToolResult> => {
750+
const agents = deps.fleetRecords.ids().map((id) => {
751+
const record = deps.fleetRecords.peek(id);
752+
const session = deps.sessions.get(id);
753+
return {
754+
agent_id: id,
755+
status: record?.status ?? "unknown",
756+
collected: record?.collected === true,
757+
...(session !== undefined
758+
? {
759+
director: session.agentId,
760+
description: session.description,
761+
lifecycle: session.lifecycleStatus,
762+
}
763+
: {}),
764+
};
765+
});
766+
return fleetResult(call.id, JSON.stringify({ agents }));
767+
},
768+
});
769+
}

src/subagent/authority.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,11 @@ describe("assertTierMayMountFleetVerb", () => {
3333

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

4543
test("Tier 1 orchestrator may mount search_agents and list_agents", () => {

src/subagent/authority.ts

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

5251
export function isFleetVerb(toolName: string): boolean {
5352
return FLEET_VERBS.has(toolName);

0 commit comments

Comments
 (0)