Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 0 additions & 12 deletions src/agent/profile-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 0 additions & 1 deletion src/agent/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ const AgentProfileSchema = type({
"systemPromptRole?": "string",
"systemPromptPath?": "string",
"orchestrator?": "boolean",
"fleetTier?": "'orchestrator' | 'nested-orchestrator' | 'leaf'",
"maxTurns?": "number",
});

Expand Down
9 changes: 2 additions & 7 deletions src/subagent/task-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/subagent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading