voyage/docs/agent-return-channel-defect.md
Kjell Tore Guttormsen ec9bbde8f8 docs(agent-return-channel): record the idle-notification signature the defect presents as
The five mute teammates surfaced nine minutes after finishing - as
content-free idle notifications. That is akashic's "innholdslose idle-pings"
verbatim, and it arrived after the write-up was committed, so it is added
here as the field signature to recognise the defect by.

Two readable facts in it:
- `summary` is populated only for the teammate that called SendMessage; the
  five tool-less ones carry no content at all, not even an error. Presence of
  `summary` is the fastest mute-vs-delivering discriminator.
- The pings lag by minutes (ctrl-pong went idle 10s after spawn, notified ~9
  min later), so "no ping yet" is not evidence of work in progress and the
  ping is not a result. Judging liveness from these is how a finished run gets
  re-prompted nine times.

Suite unchanged: 1013 (1011/0/2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MfB5Ecp8vvGyou8uFGxBV
2026-08-17 22:17:48 +02:00

13 KiB
Raw Blame History

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 symptom signature, exactly

The teammates do eventually surface — as content-free idle notifications, delivered long after the fact. Observed in this session, all six named agents (times UTC):

{"type":"idle_notification","from":"ctrl-pong",     "idleReason":"available"}   19:57:30
{"type":"idle_notification","from":"ctrl-read",     "idleReason":"available"}   19:57:35
{"type":"idle_notification","from":"pc-nooverride", "idleReason":"available"}   19:59:19
{"type":"idle_notification","from":"pc-opus",       "idleReason":"available"}   19:59:24
{"type":"idle_notification","from":"sg-opus",       "idleReason":"available"}   19:59:37
{"type":"idle_notification","from":"ctrl-sendback", "idleReason":"available",
                                   "summary":"[to main] PONG-VIA-SENDMESSAGE"}  20:06:16

This is akashic's "innholdsløse idle-pings", verbatim, and it is the field signature to recognise the defect by. Two things to read off it:

  • summary is present only for the teammate that called SendMessage. For the five tool-less ones the notification carries no content at all — not a truncated result, not an error, nothing. The presence or absence of summary is the fastest way to tell a delivering teammate from a mute one.
  • The idle notifications lag by minutes. ctrl-pong went idle 10 seconds after spawn; its notification arrived ~9 minutes later. So "no idle ping yet" is not evidence the agent is still working, and the ping, when it comes, is not a result. Judging liveness from these is how a completed run gets re-prompted nine times.

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.

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.