Skip to content

Commit dbe78b8

Browse files
committed
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 df0d855 commit dbe78b8

4 files changed

Lines changed: 49 additions & 7 deletions

File tree

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/fleet-verbs-mount.test.ts

Lines changed: 2 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
*/
@@ -21,7 +21,7 @@ const FLEET_VERBS = [
2121
] as const;
2222

2323
describe("primary fleet verb mount", () => {
24-
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 () => {
2525
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
2626
const { createAgentToolset } = await import("./tools.js");
2727
const permissionGate = {

src/subagent/authority.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
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* of the director catalog
10+
* send_input). Fleet *discovery* of the director catalog
1111
* (search_agents) is Tier 1 only (CL-7051). list_agents is not catalog
1212
* discovery — it lists this install's own spawn_agent workers, the same
1313
* scoped mailbox wait_agents uses, so nested orchestrators may mount it.

src/subagent/run-authority.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,45 @@ describe("runSubAgent passes parentSessionId into spawn_agent mount", () => {
213213
expect(capturedParentSessionId).toBe("greybeard-session");
214214
});
215215
});
216+
217+
describe("runSubAgent list_agents mount (mailbox-scoped, nested ok)", () => {
218+
test("nested-orchestrator mounts list_agents", async () => {
219+
const cwd = await tmpCwd();
220+
let listAgentsMounts = 0;
221+
222+
await withMockedModuleDuring(
223+
import.meta.resolve("./agent-fleet.js"),
224+
(real: typeof import("./agent-fleet.js")) => ({
225+
...real,
226+
createListAgentsTool: (deps: never) => {
227+
listAgentsMounts++;
228+
return real.createListAgentsTool(deps);
229+
},
230+
}),
231+
async () => {
232+
const { runSubAgent: run } = await import("./run.js");
233+
try {
234+
await run({
235+
...baseParams(cwd, join(cwd, ".ctx")),
236+
id: "greybeard-session",
237+
orchestrator: true,
238+
orchestratorTier: "nested-orchestrator",
239+
nestedDispatch: {
240+
permissionGate: testPermissionGate,
241+
getWorkdirBase: () => join(cwd, ".ctx"),
242+
provider: {
243+
providerName: "test",
244+
baseURL: "http://localhost",
245+
model: "test-model",
246+
},
247+
},
248+
});
249+
} catch {
250+
// Inference/agent construction may fail; mount decisions run first.
251+
}
252+
},
253+
);
254+
255+
expect(listAgentsMounts).toBe(1);
256+
});
257+
});

0 commit comments

Comments
 (0)