Define the subagents package and its three-tier tool surface - #594
Merged
TheGreatAxios merged 3 commits intoAug 24, 2026
Merged
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes CL-6941.
The tier model
Every director package (
src/agent/directors/types.ts) carries a requiredtier: SubagentTier:orchestrator(skywalker): full fleet control over the whole tree.nested-orchestrator(greybeard, or any package withspawn.maySpawn): same surface, scoped to its own subtree — may manage only its own descendants, never a sibling or anything above it.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.tsis the authority module:assertTierMayMountFleetVerb(tier, toolName)throws if a Tier 3 leaf is about to receive a fleet verb (task,search_agentstoday;FLEET_VERBSalso names the not-yet-builtspawn_agent/wait_agents/list_agents/send_input/interrupt_agent/close_agent/resume_agent/read_agent_trace/followup_taskso their future mount sites inherit the gate).A round of review caught that the first version of this gate failed open:
runSubAgentonly resolved a tier whenparams.directorIdwas set, which is true only for the 16 closed directors. Any project-local or pluginAgentProfilewithorchestrator: true— which is a real, already-supported feature, not hypothetical — reachedrunSubAgentwithdirectorIdundefined, the tier check was skipped entirely, andtask/search_agentsmounted 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 asRunSubAgentParams.orchestratorTier.runSubAgenttreats a missingorchestratorTieras"leaf", not as "skip the check": denied by default, not silently trusted. A profile withorchestrator: trueand nofleetTier(orfleetTier: "leaf") now gets aFleetAuthorityErrorinstead of a mountedtasktool; it must explicitly declarefleetTier: "nested-orchestrator"to opt in. (The field is namedfleetTier, nottier—AgentProfilealready has an unrelatedtierused 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 (taskonly spawns). It's landed now, explicitly marked as unwired in both the code comment above the function and indocs/ARCHITECTURE.md, so CL-6942 (split spawn from wait) and CL-6944 (send_inputsteering) — 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--configcomment, 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): drivesrunSubAgentitself, not just the assert functions — proves (a)orchestrator: truewith an unresolved tier (the profile-sourced shape) is denied withFleetAuthorityError, (b) an explicitorchestratorTier: "leaf"is denied, and (c) a resolved non-leaf tier passes the gate and fails later for an unrelated reason (missingnestedDispatch), not on authority. This is the test that would have caught the fail-open bug.src/agent/directors/registry.test.ts(new case): pinstier !== "leaf"againstspawn.maySpawnfor all 16 directors, since the two fields independently encode the same fact by hand and had nothing tying them together before this.task()is untouchedConfirmed:
task()'s behavior, argument schema, and wire contract are unchanged. It remains the only spawn verb. The tier check only governs which callers may havetask/search_agentsmounted 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-sourcedfleetTieropt-in and why it isn't namedtier.CHANGELOG.mdreflects 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.