diff --git a/CHANGELOG.md b/CHANGELOG.md index 282385557..173a9d3bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain parallel copies under `docs/` or `scripts/notes/`. At cut time: rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script. +## [Unreleased] + +### Agent + +- Removed `AgentProfile.fleetTier`: no loader ever populated it from any config + format, so it was declared but unreachable. Fail-closed behavior is + unchanged — a profile-sourced orchestrator is still denied `task`/ + `search_agents` with no supported opt-in. + ## [0.2.108] - 2026-08-24 ### Agent diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index efd7fbff6..9f50d0b42 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -239,7 +239,7 @@ 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`, or an explicit non-leaf `AgentProfile.fleetTier` opt-in for a profile-sourced orchestrator — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing `task` / `search_agents`, 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 — it must also declare `fleetTier: "nested-orchestrator"`, or the mount throws `FleetAuthorityError`. (`AgentProfile.fleetTier` is deliberately not named `tier` — that name is already used, ad hoc, by some profiles for an unrelated model-speed selector; reusing it would have silently broken schema validation for those profiles, which is exactly what happened during review and was caught by the full test suite, not by inspection.) `FLEET_VERBS` in `authority.ts` also names the not-yet-implemented verbs (`spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `followup_task`) so their future mount sites inherit the same gate. +- **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 `task` / `search_agents`, 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 (CL-6942/CL-6944 can add one when a real caller needs it). `FLEET_VERBS` in `authority.ts` also names the not-yet-implemented verbs (`spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `followup_task`) so their future mount sites inherit the same gate. - **Subtree authority — a seam, not yet wired.** `assertCanTargetAgent(actor, targetId, nodes)` (`src/subagent/authority.ts`) implements the "root owns its tree; a child manages only its own descendants" rule (Tier 1 may target anyone, Tier 2 may target only its own descendants over the same `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks, Tier 3 always fails closed) — but **it has no production call site yet**. No verb today lets one live agent address another (`task` only spawns), so this rule is exercised only by `authority.test.ts` and is not enforced at runtime in this PR. It exists so CL-6942 (split spawn from wait) and CL-6944 (`send_input` steering) — the first verbs that make an agent addressable by another — can call it from day one instead of each inventing its own check. Treat it as unenforced until one of those wires a call site. - `task()` is unaffected and remains the only spawn verb until the new verbs land beside it (deprecated-not-deleted per the CL-6940 epic). Its argument schema and wire contract are unchanged; the tier check only gates which packages may have it mounted at all. diff --git a/src/agent/profile-types.ts b/src/agent/profile-types.ts index 8ec167d08..a2bd14ec5 100644 --- a/src/agent/profile-types.ts +++ b/src/agent/profile-types.ts @@ -68,18 +68,6 @@ export interface AgentProfile { // coordinators (e.g. a planning agent that fans work out to specialists); // leaf-task agents should leave this unset. orchestrator?: boolean; - // Fleet authority tier (CL-6941) for a profile-sourced orchestrator. Only - // meaningful alongside orchestrator: true. Named `fleetTier`, not `tier` — - // `tier` is already an established profile field for model speed selection - // ("fast" | "standard" | "clever", resolved via task(tier=...)); reusing - // the name silently broke schema validation for profiles that set it. A - // profile is outside the closed director set, so it is NOT trusted with - // fleet verbs by default even when orchestrator: true is set — this must - // be declared explicitly as "nested-orchestrator" to opt in. - // Runtime-enforced at the tool-mount point (src/subagent/run.ts / - // src/subagent/authority.ts): an orchestrator=true profile with no - // fleetTier (or fleetTier: "leaf") is denied task/search_agents, fail-closed. - fleetTier?: "orchestrator" | "nested-orchestrator" | "leaf"; // Optional inference-turn budget when this profile is dispatched via task(agent=...). // Floor-sanitized (≥1) at dispatch time; task(maxTurns) overrides when set. maxTurns?: number; diff --git a/src/agent/profiles.ts b/src/agent/profiles.ts index a27f8facd..5a57a424d 100644 --- a/src/agent/profiles.ts +++ b/src/agent/profiles.ts @@ -51,7 +51,6 @@ const AgentProfileSchema = type({ "systemPromptRole?": "string", "systemPromptPath?": "string", "orchestrator?": "boolean", - "fleetTier?": "'orchestrator' | 'nested-orchestrator' | 'leaf'", "maxTurns?": "number", }); diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index fa95ee4c8..26bb0cef2 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -486,14 +486,9 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { if (profile.orchestrator === true && deps.allowOrchestrator !== false) { orchestrator = true; // Fail closed (CL-6941): a profile is outside the closed director - // set, so orchestrator: true alone does not grant a tier. Only an - // explicit non-leaf profile.fleetTier opts in; anything else - // (absent, or "leaf") leaves orchestratorTier undefined, which + // set, so orchestrator: true alone does not grant a tier. No + // profile field opts in; orchestratorTier stays undefined, which // runSubAgent treats as "leaf" and denies task/search_agents. - orchestratorTier = - profile.fleetTier !== undefined && profile.fleetTier !== "leaf" - ? profile.fleetTier - : undefined; } // Per-agent pinned inference (provider/model/effort), if declared. // Resolution uses policy (mode: pin / agentModelFallback: none) so a diff --git a/tests/unit/subagent.test.ts b/tests/unit/subagent.test.ts index f499b261d..727e8fa5e 100644 --- a/tests/unit/subagent.test.ts +++ b/tests/unit/subagent.test.ts @@ -972,5 +972,9 @@ describe("createTaskTool profile resolution", () => { await callHandler(tool, { description: "task", prompt: "do it", agent: "karen" }); expect(received?.orchestrator).toBe(true); + // Fail-closed (CL-6941): no profile field opts a profile-sourced + // orchestrator into fleet verbs, so the tier stays unresolved and + // runSubAgent treats it as "leaf" — denied task/search_agents. + expect(received?.orchestratorTier).toBeUndefined(); }); });