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:
parent
4b261db005
commit
f79c5f6606
6 changed files with 297 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
207
docs/agent-return-channel-defect.md
Normal file
207
docs/agent-return-channel-defect.md
Normal 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.
|
||||
|
|
@ -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')) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue