Skip to content

Commit df0d855

Browse files
committed
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.
1 parent 6e93b83 commit df0d855

10 files changed

Lines changed: 114 additions & 14 deletions

File tree

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ 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",

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: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
* - assertTierMayMountFleetVerb: a Tier 3 leaf may never mount a fleet verb
88
* (task, spawn_agent, wait_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+
* list_agents, 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);

src/subagent/run.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,12 @@ import {
107107
DEFAULT_CLOSE_DEADLINE_MS,
108108
} from "./dispose.js";
109109
import { createTaskTool } from "./task-tool.js";
110-
import { createFleetRecords, createSpawnAgentTool, createWaitAgentsTool } from "./agent-fleet.js";
110+
import {
111+
createFleetRecords,
112+
createSpawnAgentTool,
113+
createWaitAgentsTool,
114+
createListAgentsTool,
115+
} from "./agent-fleet.js";
111116
import {
112117
createCloseAgentTool,
113118
createResumeAgentTool,
@@ -500,6 +505,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
500505
"read_agent_trace",
501506
"spawn_agent",
502507
"wait_agents",
508+
"list_agents",
503509
"close_agent",
504510
"resume_agent",
505511
"interrupt_agent",
@@ -595,6 +601,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
595601
...tools,
596602
createSpawnAgentTool(fleetDeps),
597603
createWaitAgentsTool({ sessions: fleetSessions, fleetRecords }),
604+
createListAgentsTool({ sessions: fleetSessions, fleetRecords }),
598605
createCloseAgentTool({ sessions: fleetSessions, fleetRecords }),
599606
createResumeAgentTool({ sessions: fleetSessions }),
600607
createInterruptAgentTool({ sessions: fleetSessions, fleetRecords }),

0 commit comments

Comments
 (0)