Skip to content

Commit 4567fa0

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 e885cc1 commit 4567fa0

10 files changed

Lines changed: 110 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 }),
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";
@@ -572,3 +573,50 @@ describe("interrupt_agent unblocks wait_agents", () => {
572573
expect(results[0]!.report).toContain("partial");
573574
});
574575
});
576+
577+
describe("list_agents", () => {
578+
test("lists this fleet only, including director and lifecycle", async () => {
579+
const gate = deferred<RunSubAgentResult>();
580+
const deps = makeDeps(async () => gate.promise);
581+
deps.sessions.start({
582+
id: "foreign-sibling",
583+
description: "someone else's worker",
584+
agentId: "explorer",
585+
brief: "b",
586+
});
587+
const spawn = createSpawnAgentTool(deps);
588+
const list = createListAgentsTool({
589+
sessions: deps.sessions,
590+
fleetRecords: deps.fleetRecords,
591+
});
592+
const spawned = await callTool(spawn, {
593+
description: "mine",
594+
prompt: "do it",
595+
intent: "explore",
596+
});
597+
if (list.kind !== "full") throw new Error("expected full tool");
598+
const raw = await list.handler(
599+
{ id: "list-1", name: "list_agents", arguments: {} },
600+
new AbortController().signal,
601+
);
602+
const content = typeof raw.content === "string" ? raw.content : JSON.stringify(raw.content);
603+
const parsed = JSON.parse(content) as {
604+
agents: {
605+
agent_id: string;
606+
status: string;
607+
collected: boolean;
608+
director?: string;
609+
description?: string;
610+
lifecycle?: string;
611+
}[];
612+
};
613+
expect(parsed.agents).toHaveLength(1);
614+
expect(parsed.agents[0]!.agent_id).toBe(spawned.agent_id as string);
615+
expect(parsed.agents[0]!.status).toBe("running");
616+
expect(parsed.agents[0]!.collected).toBe(false);
617+
expect(parsed.agents[0]!.director).toBe("explorer");
618+
expect(parsed.agents[0]!.description).toBe("mine");
619+
expect(parsed.agents[0]!.lifecycle).toBe("pending_init");
620+
gate.resolve({ report: "done" });
621+
});
622+
});

src/subagent/agent-fleet.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,3 +716,40 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool {
716716
},
717717
});
718718
}
719+
720+
export const listAgentsToolDefinition: ToolDefinition = {
721+
name: "list_agents",
722+
description:
723+
"List the workers this session started with spawn_agent — the same fleet wait_agents " +
724+
"collects. Does not list siblings or another orchestrator's workers. Each entry is id, " +
725+
"director, description, wait status, lifecycle, and whether wait_agents already collected it.",
726+
inputSchema: {
727+
type: "object",
728+
properties: {},
729+
},
730+
};
731+
732+
export function createListAgentsTool(deps: WaitAgentsDeps): AgentTool {
733+
return tool({
734+
definition: listAgentsToolDefinition,
735+
handler: async (call, _signal): Promise<ToolResult> => {
736+
const agents = deps.fleetRecords.ids().map((id) => {
737+
const record = deps.fleetRecords.peek(id);
738+
const session = deps.sessions.get(id);
739+
return {
740+
agent_id: id,
741+
status: record?.status ?? "unknown",
742+
collected: record?.collected === true,
743+
...(session !== undefined
744+
? {
745+
director: session.agentId,
746+
description: session.description,
747+
lifecycle: session.lifecycleStatus,
748+
}
749+
: {}),
750+
};
751+
});
752+
return fleetResult(call.id, JSON.stringify({ agents }));
753+
},
754+
});
755+
}

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 }),
599606
createResumeAgentTool({ sessions: fleetSessions }),
600607
createInterruptAgentTool({ sessions: fleetSessions, fleetRecords }),

0 commit comments

Comments
 (0)