Skip to content

Define the subagents package and its three-tier tool surface - #594

Merged
TheGreatAxios merged 3 commits into
mainfrom
cl-6941-define-the-subagents-package-and-its-three-tier-tool-surface
Aug 24, 2026
Merged

Define the subagents package and its three-tier tool surface#594
TheGreatAxios merged 3 commits into
mainfrom
cl-6941-define-the-subagents-package-and-its-three-tier-tool-surface

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Closes CL-6941.

The tier model

Every director package (src/agent/directors/types.ts) carries a required tier: SubagentTier:

  • Tier 1 — orchestrator (skywalker): full fleet control over the whole tree.
  • Tier 2 — nested-orchestrator (greybeard, or any package with spawn.maySpawn): same surface, scoped to its own subtree — may manage only its own descendants, never a sibling or anything above it.
  • Tier 3 — leaf (every other director): no fleet verbs at all.

All 16 director packages were assigned a tier: skywalker → orchestrator, greybeard → nested-orchestrator, the other 14 → leaf.

Where enforcement lives, and how it fails closed

src/subagent/authority.ts is the authority module: assertTierMayMountFleetVerb(tier, toolName) throws if a Tier 3 leaf is about to receive a fleet verb (task, search_agents today; FLEET_VERBS also names the not-yet-built 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 gate).

A round of review caught that the first version of this gate failed open: runSubAgent only resolved a tier when params.directorId was set, which is true only for the 16 closed directors. Any project-local or plugin AgentProfile with orchestrator: true — which is a real, already-supported feature, not hypothetical — reached runSubAgent with directorId undefined, the tier check was skipped entirely, and task/search_agents mounted unconditionally. That defeated the epic's first settled decision (runtime enforcement, not advisory) for exactly the callers most likely to be user-authored.

Fixed: tier resolution now happens once, in task-tool.ts, for every dispatch path — closed director (DirectorPackage.tier) or profile (AgentProfile.fleetTier, a new opt-in field) — and is forwarded as RunSubAgentParams.orchestratorTier. runSubAgent treats a missing orchestratorTier as "leaf", not as "skip the check": denied by default, not silently trusted. A profile with orchestrator: true and no fleetTier (or fleetTier: "leaf") now gets a FleetAuthorityError instead of a mounted task tool; it must explicitly declare fleetTier: "nested-orchestrator" to opt in. (The field is named fleetTier, not tierAgentProfile already has an unrelated tier used ad hoc by some profiles for model-speed selection (fast/standard/clever); reusing the name silently broke schema validation for those profiles, which the full test suite caught.)

assertCanTargetAgent(actor, targetId, nodes) — the "root owns its tree; a child manages only its own descendants" subtree rule — is a seam, not yet a live gate. It has no production call site in this PR: no verb today lets one live agent address another (task only spawns). It's landed now, explicitly marked as unwired in both the code comment above the function and in docs/ARCHITECTURE.md, so CL-6942 (split spawn from wait) and CL-6944 (send_input steering) — the first verbs that make an agent addressable — can call it from day one instead of each inventing its own check. Do not read this as enforced until one of those wires it in.

This distinction matters here specifically: four other mechanisms in this codebase (writePaths, report.requiredSections, a --config comment, the thrash matcher) were previously documented as enforced while enforcing nothing. Both the fail-open bug above and the unwired seam are called out explicitly so neither becomes a fifth instance.

Tests

  • src/subagent/authority.test.ts: the assert functions throw/pass correctly in isolation (leaf denied a fleet verb; Tier 2 can target its own descendant and itself but not a sibling or ancestor; Tier 1 can target anyone; Tier 3 can target no one).
  • src/subagent/run-authority.test.ts (new, gate-level): drives runSubAgent itself, not just the assert functions — proves (a) orchestrator: true with an unresolved tier (the profile-sourced shape) is denied with FleetAuthorityError, (b) an explicit orchestratorTier: "leaf" is denied, and (c) a resolved non-leaf tier passes the gate and fails later for an unrelated reason (missing nestedDispatch), not on authority. This is the test that would have caught the fail-open bug.
  • src/agent/directors/registry.test.ts (new case): pins tier !== "leaf" against spawn.maySpawn for all 16 directors, since the two fields independently encode the same fact by hand and had nothing tying them together before this.

task() is untouched

Confirmed: task()'s behavior, argument schema, and wire contract are unchanged. It remains the only spawn verb. The tier check only governs which callers may have task / search_agents mounted at all — main stays shippable at every commit.

Docs

docs/ARCHITECTURE.md's "Fleet authority tiers" section now states plainly which check is live-and-fails-closed (assertTierMayMountFleetVerb) versus an unwired seam (assertCanTargetAgent), and covers the profile-sourced fleetTier opt-in and why it isn't named tier. CHANGELOG.md reflects the fail-closed behavior, not the earlier (incorrect) claim.

Gate

bun run check (lint + typecheck + build + test), run in the foreground: 5379 tests pass, 0 fail.

Every director package now carries a required tier (orchestrator /
nested-orchestrator / leaf), enforced in code at runSubAgent's existing
tool-mount point via src/subagent/authority.ts, not by prompt wording.
A Tier 3 leaf can never mount a fleet verb, and a Tier 2 nested
orchestrator can only target its own descendants. task() is unchanged.
@linear-code

linear-code Bot commented Aug 24, 2026

Copy link
Copy Markdown

CL-6941

It has no production call site yet — no verb today lets one live agent
address another, so the subtree rule is exercised only by its test.
Flagged in code and docs so it is not mistaken for enforced, the same
trap writePaths/report.requiredSections/the --config comment/the
thrash matcher fell into. assertTierMayMountFleetVerb is unaffected
and remains wired at src/subagent/run.ts.
The tier gate in runSubAgent only checked closed-director dispatches:
a project-local or plugin AgentProfile with orchestrator: true has no
directorId, so the gate was skipped and both task and search_agents
mounted unconditionally. That is the exact "advisory, not binding"
failure mode this epic exists to remove.

task-tool.ts now resolves an explicit tier for every dispatch (closed
DirectorPackage.tier, or a profile's opt-in fleetTier field — never
"tier", which collides with the existing model-speed tier some
profiles already set) and forwards it as orchestratorTier.
runSubAgent treats a missing orchestratorTier as "leaf" and denies
task/search_agents instead of skipping the check.

Added: a gate-level test driving runSubAgent itself (not just the
assert functions) to prove an unresolvable tier and an explicit leaf
tier are both denied, and that a resolved non-leaf tier passes the
gate; a registry-wide test pinning tier against spawn.maySpawn for all
16 directors so the two hand-maintained fields cannot silently drift.
@TheGreatAxios
TheGreatAxios merged commit ea84995 into main Aug 24, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant