@argszero/cordis-plugin-team-delegation-guard
v0.1.0
Published
Withdraws the legacy delegation tools (subagent/subagent_fork) from every agent the Agent Teams runtime owns, so a Team session's delegation goes through the Team tools alone. On Web/Desktop the Agent Teams bundle's own `disabled` patches hit host-plane r
Maintainers
Readme
@argszero/cordis-plugin-team-delegation-guard
Withdraws the legacy delegation tools (subagent, subagent_fork) from every
agent the Agent Teams runtime owns, so a Team session's delegation goes through
the Team tools alone.
Source discussion: #7642 — Agent Teams bundle 在 Web/Desktop 未禁用 subagent.
npm install @argszero/cordis-plugin-team-delegation-guard# profile / bundle layer; mount it anywhere
- insert:
- id: team-delegation-guard
name: '@argszero/cordis-plugin-team-delegation-guard'Nothing else to configure. The plugin reads as inert in any composition that does not mount the Agent Teams runtime.
The problem it holds the composition to
Mount the Agent Teams bundle on Web/Desktop and one session offers two mutually incompatible delegation surfaces at once:
| surface | tools |
|---|---|
| Agent Teams | spawn_teammate, send_message, list_agents, interrupt_agent, wait_agent, team_task_* |
| legacy direct delegation | subagent, subagent_fork |
The Team versions of send_message / list_agents / interrupt_agent are
registered into each agent's own layer, so they shadow the preset's
same-named continuable-child controls. subagent and subagent_fork have no
Team twin, so they survive. A model that delegates through subagent — the path
its own tool description teaches, and the one it takes when nobody mentions
Teams — creates a child that is not a Team member:
subagent({...}) -> child runs and returns normally
list_agents() -> [{"target":"lead","role":"lead",...}] // the lead alone
send_message({target:"<id>"}) -> Error: active teammate "190afdd6-…" not foundThe error names no alternative, and the Team policy text never says that this
session's send_message only accepts Team members. Every ordinary delegation on
such a session is a dead end.
Why the bundle's own patch cannot fix it
experimental/agent-team-profile/cordis.patch.yml:4-14 disables the four legacy
rows by id, and the bundle README states twice that ordinary subagent delegation
is disabled (README.md:12, :49). On Web those ids belong to the host
plane, and bundle/web-app/cordis.patch.yml:504-514 had already disabled all
four — the profile's patch re-sets an already-false flag and is silently
inert. No warning is produced, because the ids do match rows.
The rows the model actually sees are declared by the preset plane
(bundle/web-app/presets/standard.patch.yml:80-118, and the ptc / cordis
presets). A top-level patch cannot reach a preset row:
vendor/include/src/index.tsbuildMaprecurses only into same-levelgrouprows, and an unmatched id is skipped;scripts/verify-cordis-config.ts::validatePresetPlaneSeparationenforces that a row belongs to exactly one plane.
That is a deliberate two-plane architecture, and this plugin does not pretend otherwise — see What this is not.
What the plugin does
For every agent whose scope carries the Team tool set, it removes the legacy delegation names that agent can still see, using the seam the runtime already provides:
agent.ctx.tools.restrict({ deny: [...namesVisibleToThatAgent] })The Team tool set is the signal. @deepseek-ai/dsh-experimental-tool-agent-team
registers spawn_teammate into agent.ctx for every agent the Team runtime
owns (tool-agent-team/src/index.ts:164-176), and nothing else in the tree ever
registers that name. So:
- Team absent → no marker ever appears → the guard does nothing. Whichever delegation tool the composition exposes is the composition's own choice.
- Team present → agents it installs tools for lose the legacy names; agents it does not own (a plain subagent, another session) keep theirs.
- Upstream adopts the core-side fix →
subagentis no longer visible → the guard finds nothing to deny and goes inert again, with no configuration change and no version pin.
Three properties of the seam are what make this precise instead of approximate:
restrict()filters the inherited surface only.core/tools/src/index.tsview()applies restrictions to the global layer and every non-own ancestor layer, then merges the scope's own registrations after the filter, outside it. Preset rows are an ancestor contribution, so they are maskable; the Team tools are an own-layer contribution, so they are not. That asymmetry is exactly what the fix needs.restrict()throws on a name it cannot resolve rather than ignoring it (core/tools/src/index.ts:1096-1122). So the deny list is built from the live view, never from a constant. A name that turns out to be own-layer is dropped and reported on its own instead of taking the whole batch down with it.- The restriction is owned by
agent.ctx. It lifts with the agent, and leaves no residue on the ancestor plane.
The three Team-shadowed controls are deliberately not denied
send_message, interrupt_agent and list_agents are in the four rows the
bundle disables, but denying them here would be a no-op: Team registers all
three in the agent's own layer, which restrict() exempts by construction. The
default deny list therefore covers only the names the seam can actually reach.
Listing them would look like coverage without being coverage, which is the
failure mode this plugin exists to correct.
Configuration
| field | default | meaning |
|---|---|---|
| mode | enforce | enforce masks the names, warn reports them and changes nothing, off disables the guard |
| markers | ['spawn_teammate'] | tool names that mark an agent as Team-owned; replaces the default |
| deny | ['subagent', 'subagent_fork', 'subagent_codex', 'subagent_claude_code'] | legacy delegation names to withdraw; replaces the default |
- set:
- id: team-delegation-guard
config:
mode: warn # see what the composition leaks before enforcing
deny: ['subagent']subagent_codex and subagent_claude_code are the one-shot subagent providers,
declared disabled: true by default in presets/standard.patch.yml. They are
on the default list because a composition that deliberately enabled them under
Team lands in the same trap, and because a name that is not registered costs
nothing — the list is filtered against the live view before use.
Ordering
The guard and tool-agent-team are independent mounts that both work off
agent/created, so either may run first. The guard leaves an agent undecided
until its marker appears, and rechecks undecided agents on tools/change — an
unfiltered registry notification emitted by every registration and every
restriction. Each agent is decided once, so a later change cannot stack
restrictions.
What this is not
- Not the core-side fix. The recommended change is a per-row
disabled: !!js "ctx.get('agentTeams') !== undefined"on the four legacy child-control rows inpresets/standard|ptc|cordis.patch.yml— per row, not on the wholedelegationgroup, which also carriesworkflow(headless Team snapshots retain it). When that lands, this plugin goes inert by itself. - Not a roster change. It cannot make a
subagentchild into a Team member. That is the roster, in@deepseek-ai/dsh-experimental-agent-team, wheretryMembershipreturnsundefinedfor a subagent-descriptor child (agent-team/src/roster.ts:92-122). - Not a policy document. It changes which tools exist, not what the model is told about them. If the composition wants delegation to remain possible alongside Team, it should say so in the Team tool descriptions.
Verification
npm test # 18 behaviour arms + 6 packaging armsThe behaviour suite drives the real @deepseek-ai/cordis context, the real
ToolRuntime, and the real scope chain linking a preset-plane ancestor scope to
an agent scope; only the agent registry is stubbed. Enforcement arms are paired
with control arms — an agent the Team runtime does not own must keep subagent,
and an own-layer registration must survive the withdraw — because a guard that
removes tools in every composition would be worse than one that removes none.
The packaging suite asks npm itself (npm pack --dry-run --json) what the
tarball would contain, then requires the entry's relative import closure to be
inside it. It has already caught one release defect in this package: a file
declared in files that did not exist is omitted from the tarball with no
warning at all.
Compatibility
Verified against dsh 0.1.7-rc.1 (source HEAD 46a7f68b09). The peer range
spans the prerelease lines in which the restriction seam is unchanged:
0.1.5-rc.3 through 0.1.7-rc.1 — the view() / restrict() logic was
compared across those releases and differs only in an internal accessor name.
The plugin uses no private API: ctx.agents.list/get, ctx.tools.get,
agent.ctx.tools.restrict, and agent/created / agent/disposed /
tools/change.
License
MIT
