fix(spawn): forbid the Agent tool's name parameter at every voyage spawn site

akashic-intelligence lost /trekplan Phase 9 twice to reviewer agents that
never returned. Reproduced here and measured: the cause is the Agent tool's
`name` parameter, not the agent definitions.

Passing `name` does not label a subagent - it changes its kind. The spawn is
recorded as taskKind "in_process_teammate" (spawnDepth 0) instead of a real
subagent (spawnDepth 1). A teammate's final assistant text is not a return
value; it reaches the parent only if the teammate itself calls
SendMessage(to: "main"). plan-critic and scope-guardian declare
tools: [Read, Glob, Grep] - no SendMessage - so as teammates they are
structurally incapable of returning, whatever the prompt says.

Denominators: named 0/5 returned; named + explicit SendMessage 1/1; unnamed
3/3 (plan-critic and scope-guardian each returned full findings + the JSON
block in ~110s). Model override 2/2 non-returning, so the override is not
the variable. All 5 named agents produced correct final text in their
transcripts - only delivery failed, and that output is recoverable on disk.

The defect is a harness behaviour, so it is documented rather than silently
worked around: docs/agent-return-channel-defect.md carries the mechanism,
every denominator, the two broken queries that nearly became facts, the
unmeasured cells, and a recommended working shape for consumers (use agents,
drop `name`; do not fall back to inline review, which costs the dedup step).

Prevention is pinned, TDD red->green: the four spawning commands each state
the rule and name the mechanism, and doc-consistency derives the spawning set
from the command files so the pin cannot go vacuous.

Suite 1013 (1011/0/2), +5 from baseline 1008.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MfB5Ecp8vvGyou8uFGxBV
This commit is contained in:
Kjell Tore Guttormsen 2026-08-17 22:10:11 +02:00
commit f79c5f6606
6 changed files with 297 additions and 0 deletions

View file

@ -507,6 +507,18 @@ is available, pass nothing — the reviewer scores the memory-alignment dimensio
Launch the `brief-reviewer` agent (foreground, blocking) with the prompt:
> **Never pass the Agent tool's `name` parameter — at this or any other spawn
> site in this command.** `name` does not label a subagent, it changes its kind:
> the spawn is recorded as `taskKind: "in_process_teammate"` (`spawnDepth: 0`)
> instead of a subagent (`spawnDepth: 1`). A teammate's final assistant text is
> not a return value — it reaches the orchestrator only if the teammate itself
> calls `SendMessage(to: "main")`, and every agent in `agents/` declares a
> `tools:` allowlist without SendMessage. The agent still runs and still
> produces correct output; the result is simply never delivered, so the phase
> presents as a hung agent that no re-prompting can revive. Measured
> 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism, denominators, and the
> on-disk recovery path: `docs/agent-return-channel-defect.md`.
> "Review this task brief for quality: `{PROJECT_DIR}/brief.md.draft`.
> Check completeness, consistency, testability, scope clarity,
> research-plan validity, and memory alignment. Report findings, verdict, and

View file

@ -406,6 +406,18 @@ the quick-scan results.
**All other modes:** Launch exploration agents **in parallel** (all in a single
message). Use the specialized agents from the `agents/` directory.
> **Never pass the Agent tool's `name` parameter — at this or any other spawn
> site in this command.** `name` does not label a subagent, it changes its kind:
> the spawn is recorded as `taskKind: "in_process_teammate"` (`spawnDepth: 0`)
> instead of a subagent (`spawnDepth: 1`). A teammate's final assistant text is
> not a return value — it reaches the orchestrator only if the teammate itself
> calls `SendMessage(to: "main")`, and every agent in `agents/` declares a
> `tools:` allowlist without SendMessage. The agent still runs and still
> produces correct output; the result is simply never delivered, so the phase
> presents as a hung agent that no re-prompting can revive. Measured
> 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism, denominators, and the
> on-disk recovery path: `docs/agent-return-channel-defect.md`.
**All agents run for all codebase sizes.** Scale `maxTurns` by size (small: halved,
medium: default, large: default) instead of dropping agents.

View file

@ -387,6 +387,18 @@ other agents — the value of Gemini is independence.
- Launch ALL selected agents **in parallel** in a single message
- Use model: "opus" for all sub-agents (the orchestrator runs on Opus)
- **Never pass the Agent tool's `name` parameter — at this or any other spawn
site in this command.** `name` does not label a subagent, it changes its
kind: the spawn is recorded as `taskKind: "in_process_teammate"`
(`spawnDepth: 0`) instead of a subagent (`spawnDepth: 1`). A teammate's final
assistant text is not a return value — it reaches the orchestrator only if
the teammate itself calls `SendMessage(to: "main")`, and every agent in
`agents/` declares a `tools:` allowlist without SendMessage. The agent still
runs and still produces correct output; the result is simply never delivered,
so the phase presents as a hung agent that no re-prompting can revive.
Measured 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism,
denominators, and the on-disk recovery path:
`docs/agent-return-channel-defect.md`.
- Scale maxTurns by codebase size for local agents (same as trekplan):
small = halved, medium/large = default
- convention-scanner: medium+ codebases only (50+ files)

View file

@ -199,6 +199,18 @@ described in the rest of this phase.
Launch two reviewer agents **in parallel** via the Agent tool — one
message, multiple tool calls.
> **Never pass the Agent tool's `name` parameter — at this or any other spawn
> site in this command.** `name` does not label a subagent, it changes its kind:
> the spawn is recorded as `taskKind: "in_process_teammate"` (`spawnDepth: 0`)
> instead of a subagent (`spawnDepth: 1`). A teammate's final assistant text is
> not a return value — it reaches the orchestrator only if the teammate itself
> calls `SendMessage(to: "main")`, and every agent in `agents/` declares a
> `tools:` allowlist without SendMessage. The agent still runs and still
> produces correct output; the result is simply never delivered, so the phase
> presents as a hung agent that no re-prompting can revive. Measured
> 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism, denominators, and the
> on-disk recovery path: `docs/agent-return-channel-defect.md`.
Reviewers run independently. Do NOT pre-feed findings between them.
| Agent | Mode-gated | Purpose |

View file

@ -0,0 +1,207 @@
# The Agent tool's `name` parameter silently destroys the return channel
**Date:** 2026-08-17 (voyage S85)
**Claude Code:** 2.1.226 · **Plugin:** voyage 5.9.1 (installed cache — verified byte-identical to the repo working tree for both reviewer agents before measuring)
**Status:** Root cause MEASURED and mechanistically confirmed. The defect is in
how the harness treats one Agent-tool parameter, not in voyage's agent
definitions. A one-line workaround exists and is now pinned in every voyage
spawn site.
## The report that triggered this
`akashic-intelligence` lost `/trekplan` Phase 9 twice. S25 (2026-08-17, plan
rev 5.0): four subagents spawned via the Agent tool — `voyage:plan-critic` ×2,
`voyage:scope-guardian` ×2, all `model: "opus"`, not Workflow. Nine
`SendMessage` reminders, zero replies. The round before had the same outcome
with tighter scope and an explicit tool-call budget. Consequence: the
hand-dedup step fell away (one source instead of two) and review coverage came
out **narrower** than the four preceding rounds.
Their own conclusion was "the channel, not the agents" — directionally right.
Two details in it are wrong and matter: they attributed it to the agents having
no `Write` tool, and they concluded the remedy is to abandon agents and run the
review inline. `Write` is irrelevant, and the agents do not need abandoning.
## Root cause
**Passing `name` to the Agent tool does not name a subagent. It changes what
kind of thing gets spawned.**
The spawn metadata says it outright. From
`~/.claude/projects/<project>/<session>/subagents/agent-*.meta.json`, same
session, same model, same `subagent_type`:
| spawn | recorded `agentType` | `taskKind` | `spawnDepth` |
|---|---|---|---|
| without `name` | `voyage:plan-critic` | *(none — a real subagent)* | `1` |
| with `name` | `pc-opus` (the name) | `in_process_teammate` | `0` |
The two kinds have different return semantics:
- **Subagent** (`spawnDepth: 1`) — the agent's final assistant text **is** the
return value. It arrives at the parent as a task notification.
- **Teammate** (`in_process_teammate`, `spawnDepth: 0`) — a peer of the main
session, not a child of it. Its plain final text is **not** a return value; it
is only transcript. A teammate reaches the parent **only** by calling
`SendMessage(to: "main")`.
Every voyage agent declares a `tools:` allowlist. `plan-critic` and
`scope-guardian` declare `["Read", "Glob", "Grep"]`. **No SendMessage.** As
teammates they are therefore *structurally* incapable of returning anything —
no prompt, no scope tightening, and no tool-call budget can change that. This
is exactly why akashic's second attempt with a stricter prompt failed
identically: the prompt was never the variable.
### Why it looks like a hung agent
The teammate runs. It reads the plan, reasons, and writes a complete, correct
final answer to its transcript. Then it stops. From the orchestrator's side
this is indistinguishable from an agent that stalled — so the natural response
is to poke it with `SendMessage`, which produces more transcript that also
never comes back. Nine reminders, zero replies.
**The work is not lost.** It is on disk at
`~/.claude/projects/<project-slug>/<session-id>/subagents/agent-a<name>-<hash>.jsonl`.
The final assistant text block in that file is the answer the orchestrator never
received. This is the recovery path when a run has already been burned.
## Measurements
One session, one target unless noted. Target for the reviewer cells:
`docs/devils-advocate-plan.md` (66 lines) — a real plan file, small enough that
runtime cannot be confused with hanging.
| # | `subagent_type` | `model` | `name`? | Returned to parent? | Time |
|---|---|---|---|---|---|
| 1 | `general-purpose` ("reply PONG", 0 tools) | `opus` | yes | **no** | >17 min, never |
| 2 | `general-purpose` (read file, count headings) | `opus` | yes | **no** | never |
| 3 | `voyage:plan-critic` | `opus` | yes | **no** | never |
| 4 | `voyage:scope-guardian` | `opus` | yes | **no** | never |
| 5 | `voyage:plan-critic` | *(no override)* | yes | **no** | never |
| 6 | `general-purpose`, told to call `SendMessage(to: "main")` | `opus` | yes | **yes** | ~30 s |
| 7 | `general-purpose` ("reply PONG", 0 tools) | `opus` | **no** | **yes** | **2.19 s** |
| 8 | `voyage:plan-critic` | `opus` | **no** | **yes** — full findings + JSON block | **110.3 s**, 7 tool uses |
| 9 | `voyage:scope-guardian` | `opus` | **no** | **yes** — full findings + JSON block | **109.1 s**, 8 tool uses |
Denominators, per varied dimension:
- **`name` present, no explicit SendMessage: 0/5 returned.** `name` absent:
**3/3 returned.** This is the only dimension that moved the outcome.
- **`name` present *with* an explicit `SendMessage(to: "main")` instruction:
1/1 returned** (cell 6). This is the positive control for the teammate
channel: it is not broken, it is merely never used by tool-less agents.
- **Model override: 2/2 non-returning.** Cells 3 (`model: "opus"`) and 5 (no
override) are identical in outcome — the override is not the variable, which
rules out akashic's stated `model: "opus"` detail as causal.
- **Agent type: 2/2 in each arm.** `plan-critic` and `scope-guardian` behave
identically named (both silent) and unnamed (both complete). The agent
definitions are not the variable.
- **Work actually performed while named: 5/5.** All five named agents produced
correct, complete final text in their transcripts within ~3 minutes —
`PONG`, `8`, plan-critic's blocker list with the correct legacy-format
advisory, and scope-guardian's coverage table. Nothing failed except delivery.
### Known-positive discipline (Verifiseringsloven face 4)
Two negative results in this investigation were produced by broken queries and
would have been consumed as facts:
1. **`ListAgents` returned no in-process subagents** while five were running. It
listed 26 peer sessions and none of mine. Probing one directly with
`SendMessage` proved the agents existed and had live inboxes. Had the empty
listing been believed, the diagnosis would have been "the spawns silently
no-op" — wrong.
2. **The first known-positive control failed.** Cell 1 (named PONG) was meant to
prove the spawn mechanism works, and it did not return either. A failed
control proves nothing; it only means the control shared the defect. The
control that discriminates is cell 7 — same prompt, same model, same agent
type, `name` removed — which returned in 2.19 s.
**Unmeasured cells, stated as unmeasured:** the returning arm was only exercised
against the 66-line target. Large targets (voyage's 573-line plan;
akashic's 3730-line / 277 KB `features/01-sun-position/plan.md`) were **not**
measured in either arm. Nothing here rules out a *separate*, size-dependent
failure at akashic's scale — it only establishes that the failure they observed
reproduces at 66 lines, where size cannot be the cause.
## Where the defect lives
**Outside this repository.** Nothing in `agents/plan-critic.md` or
`agents/scope-guardian.md` is wrong: correct frontmatter, a `tools:` list
appropriate to a read-only reviewer, a terminating prompt, and an output format
that both agents produced verbatim when spawned as real subagents. The failure
is a harness behaviour — a parameter that silently changes a spawn's kind and
its return contract, with no error, no warning, and no observable difference at
the call site beyond the wording of the tool result.
Per the standing rule, this is **not** worked around silently. It is documented
here, reported to `.claude` (which owns cross-repo quality and can escalate),
and guarded in voyage's own spawn instructions so no consumer re-enters it.
## In-repo fix
Every voyage command that spawns agents — `trekbrief`, `trekplan`,
`trekresearch`, `trekreview` — now states the rule at its spawn site, naming
the mechanism (`in_process_teammate`) so it cannot be mistaken for style
preference. `trekexecute` spawns nothing (Hard Rule 10) and is excluded.
Pinned by `tests/lib/doc-consistency.test.mjs`:
- `S85: the set of agent-spawning commands is exactly the four that carry the
no-name rule` — derives the spawning set from the command files themselves, so
the pin cannot go vacuous when a command starts or stops spawning.
- `S85: commands/<cmd>.md forbids the Agent tool's name parameter at its spawn
sites` — one per command.
Verified red before the prose was written, green after.
## Recommended working shape for consumers
For anyone running voyage's reviewers (akashic included), grounded in the
measurements above rather than in preference:
**1. Use agents. Spawn them without `name`.** Cells 8 and 9 are the evidence:
both reviewers returned complete, schema-conformant output — human-readable
findings *and* the machine-readable JSON block the dedup helper consumes — in
~110 seconds, in parallel, from a single message. This is the intended shape and
it works. Removing `name` is the entire fix.
**2. Do not switch to inline review as a remedy.** akashic's fallback was
rational under their diagnosis but it pays a real, measured price: with one
source there is no dedup step, and their own record shows `scope-guardian`
finding blockers in the rev 3.0/4.0 rounds that `plan-critic` did not see at
all. The two agents are not redundant — cells 8 and 9 here returned largely
*different* findings on the same 66-line plan (plan-critic led on
placeholder/headless defects; scope-guardian led on the plan already having been
executed and its deliverable never being written). Two independent passes with
different mandates is the property worth keeping, and unnamed agents deliver it
for free. Running two inline passes is the fallback if agents fail *for a
different, verified reason* — not for this one.
**3. If you deliberately want a named teammate, give it SendMessage and say so.**
Cell 6 shows the teammate channel works when used. That means adding
`SendMessage` to the agent's `tools:` list *and* instructing it in the prompt to
call `SendMessage(to: "main")` with its result. voyage does not do this: its
reviewers are read-only by design, and the subagent path already returns.
**4. A hard tool-call budget is not the lever here.** akashic tried a tighter
budget and it changed nothing, which this measurement explains: a 0-tool-call
agent (cell 1) failed exactly like a 7-tool-call one. Budget caps are a cost
control, not a liveness control.
**5. When an agent looks hung, check its transcript before re-prompting.** The
final assistant block in
`~/.claude/projects/<project-slug>/<session-id>/subagents/agent-*.jsonl` tells
you within seconds whether the agent failed or merely could not deliver. Reading
that file directly recovers a burned run's output. Extract only the last
assistant text block — these transcripts run to 140 KB and reading one whole
will flood the orchestrator's context.
## Open
- The `--gates`-adjacent "idle-agent gap" already logged as open operator
decision #3 in `STATE.md` (`/trekreview` Phase 5, `/trekplan` Phases 5/6/9,
`/trekresearch` Phase 4 have no empty-return detection) now has a measured
root cause for its most likely trigger. Whether to add active detection — as
opposed to the prevention pinned here — remains open and is not decided by
this document.

View file

@ -1582,6 +1582,48 @@ for (const cmd of ['trekresearch', 'trekplan', 'trekreview', 'trekexecute']) {
// fixed model in every session; if deterministic pinning is ever wanted again,
// do it consciously and update this pin's rationale.
// ── S85 — every spawn site must forbid the Agent tool's `name` parameter ────
// MEASURED 2026-08-17 (this repo, session c37b89f7), after akashic-intelligence
// lost a whole Phase 9 to it. Passing `name` does not rename a subagent — it
// changes its kind: the meta record flips from a real subagent
// (`spawnDepth: 1`) to `taskKind: "in_process_teammate"` (`spawnDepth: 0`).
// A teammate's final assistant text is NOT a return value; it reaches the
// parent only if the teammate itself calls SendMessage(to: "main").
// voyage's reviewers declare `tools: ["Read","Glob","Grep"]` — no SendMessage —
// so as teammates they are STRUCTURALLY incapable of returning, whatever the
// prompt says. Denominators: named 0/5 returned, named-with-explicit-
// SendMessage 1/1, unnamed 3/3 (plan-critic + scope-guardian both returned
// full findings + JSON block in ~110s). Full measurement:
// docs/agent-return-channel-defect.md.
//
// The derived set is asserted exactly, so the pin cannot go vacuous if a
// command stops spawning or a new spawning command is added.
const SPAWNING_COMMANDS = ['trekbrief', 'trekplan', 'trekresearch', 'trekreview'];
test('S85: the set of agent-spawning commands is exactly the four that carry the no-name rule', () => {
const spawns = listMd('commands')
.filter((f) => /^Launch\b/m.test(read(`commands/${f}`)))
.map((f) => f.replace(/\.md$/, ''))
.sort();
assert.deepEqual(spawns, [...SPAWNING_COMMANDS].sort(),
'a command started or stopped spawning agents — add/remove it from SPAWNING_COMMANDS and give it the no-name rule');
});
for (const cmd of SPAWNING_COMMANDS) {
test(`S85: commands/${cmd}.md forbids the Agent tool's name parameter at its spawn sites`, () => {
const text = read(`commands/${cmd}.md`);
assert.ok(
text.includes('in_process_teammate'),
`commands/${cmd}.md must name the mechanism (in_process_teammate) so the rule is not mistaken for style`,
);
assert.ok(
/never pass .*`name`/i.test(text),
`commands/${cmd}.md must state that the Agent tool's \`name\` parameter is never passed — a named agent completes its work but its result never reaches the orchestrator`,
);
});
}
test('v5.9: no commands/*.md frontmatter carries a model: key (session inheritance by omission)', () => {
const offenders = [];
for (const f of listMd('commands')) {