reply terminates every returning agent — including the headless root
The gate on reply is not “sub-agent vs root” but “does this agent return.”
Every agent that terminates-and-returns — every sub-agent, and the root in
headless (non-interactive) mode — finishes through reply; it is the sole return
path, mandatory and enforced by bounded re-nudging rather than a final-prose
scrape. The interactive root, which converses across turns and never returns,
keeps reply withheld. The reply payload is rendered to its consumer: prose for a
model parent, a faithful value for the headless harness.
This extends agent-reply-tool, whose
“reply is sub-agent-only, withheld from the root” was reasoning about the
interactive root and never reached the headless one.
Context
agent-reply-tool made reply the way a
sub-agent constructs and returns its value, severing the positional final-prose
scrape that lost reports. It made two choices this decision revisits:
-
It withheld
replyfrom the root (“the root talks to the user across turns and never ‘returns’… Unadvertised at the root, there is no root-level termination to define”). That premise is interactive-only. The headless root does return: it is seeded once, produces one result, and the process exits. Today that result is the prose of the root’s last completed message —Headlessrolls root tokens intocur_msgand, at eachKind::Boundary, doesself.final_msg = std::mem::take(&mut self.cur_msg)(headless.rs:270-272), whichresult_jsonemits as"result"(headless.rs:152). That is exactly the positional scrape 260622 indicted: a thin final sign-off wins over the substantive earlier step, and a trailing tool-only step (no text tokens) blankscur_msgand empties the result. The headless root still carries the bug 260622 fixed for sub-agents, because 260622 never reached it. (Text mode is unaffected — it streams every root token to stdout live and has nofinal_msg.) -
It accepted a no-reply finish as one-shot-nudge-then-
Emptyand explicitly rejected “mandatoryreplywith forcedtool_choice.” Fail-fast spares wasted rounds, but it lets a single forgottenreplydiscard the agent’s whole report: the work is committed to the agent’s log and rail but is not the value handed up. Fail-fast and forced-choice are not the only two options.
Decision
-
replyis advertised to every returning agent. The discriminator on 260622’s reply-gating tool-set axis changes fromis_roottois_root && interactive: a non-interactive (headless) root is a returning agent and advertisesreply; the interactive root does not. The discriminator is the sameinteractiveflag that already setspark_when_idle(session.rs:313), so “returning” and “does not park for a human” are one fact, read in one place. Themust_replynudge gate (session.rs:555, today!is_root) and thestageallow-check backstop (session.rs:773) move in lockstep with the tool gate. -
replyis mandatory; a no-reply finish is re-nudged within budget, then fails — no scrape, no fallback. This reverses 260622’s “nudge once thenEmpty.” The reversal is bounded: re-nudging rides the existing per-turn nudge budget (nudge.rs,BUDGET) and the step cap as the backstop — it is not forcedtool_choice. 260622 rejected forcing a tool call mid-confusion (it corrupts the payload) and was right to; the distinction it missed is between forcing the call (bad) and insisting within budget, then failing honestly (good). A returning agent that never replies isFailed, not silentlyEmpty: the sub-agent’s parent sees a failure tag; the headless root’sresultis honestly empty/failed — which beats a confident trailing fragment that reads as an answer but is not one. -
The tool is uniform; the sink renders the payload to its consumer.
replycarries a value, rendered by 260622’s rule (a JSON string passes through raw, so markdown keeps real newlines; a JSON object/array is structured). The two return sinks differ because their consumers differ:- Sub-agent → a model parent that reads the value as prose in its context
(
AgentResult.text→ the↘block). The object case is pretty-printed to text — 260622’s text-out boundary, unchanged. - Headless root → a machine (the harness parsing
--output-format json). Its payload is written toresultas a faithful value: a string stays a string, an object/array stays an object/array. Pretty-printing structure into the string field would double-encode it — the harness then re-parses JSON-out-of-a-JSON-string, the escaped-\nhazardshell_eval.rs:298-300already names.
- Sub-agent → a model parent that reads the value as prose in its context
(
-
The headless
resultfield becomesstring | object. A deliberate divergence from Claude Code’s always-string shape. 260622’s parity goal was “field names mirror so a harness can parse either agent”; typingresultfaithfully keeps the names and the spirit — a run that asked for structure receives structure — while admitting a union the harness must handle. The always-stringify alternative preserves the exact shape at the cost of double-encoding, which defeats allowing JSON at all. -
replyis rejectable — on conformance, never on syntax. Tool-call arguments arrive already parsed (260622: “reply’s argument arrives as JSON already”); a syntactically broken call fails upstream at the provider parse layer and never reachesreply. So insidereplyan object is valid by construction and a string is legitimate prose — there is nothing to syntax-check. Rejection fires only against an expectation: a wrong call-shape (the existinginvalid_inputpath) or, when the run requested a structured format, a payload that does not conform. A rejected reply re-nudges exactly like a no-reply. exarch must not parse a free reply string as JSON and fail when it does not parse — that re-creates the hand-serialized-JSON anti-pattern this decision removes and would reject a valid markdown answer that merely contains a{. -
Schema-validated payloads stay deferred. The freeform default — string = prose, object = structure — needs no validation. A declared output schema (oh-my-pi’s JTD
yield, deferred in 260622) is the home for the conformance rejection above; this decision provides the hook (reply is rejectable and re-nudged) without the schema machinery. Layer it on when a structured agent needs the guarantee.
Why this shape
- One return contract, not two. The positional scrape is removed everywhere it
survived — sub-agent returns (260622) and the headless result (here) — by the
same mechanism: a deliberate
replyartifact rather than whatever prose happened to land last. - The gate matches the real distinction. “Returning vs conversing” is the actual axis. “Sub-agent vs root” was a proxy that read correctly only while the sole returning agents were sub-agents; headless makes the root a returning agent, so the proxy breaks and the real axis shows through.
- Honest failure over confident fragments. A run that never
reply-ed did not complete its contract. Reporting that — a failure tag, an emptyresult— is truer than scraping a trailing sentence that masquerades as the answer. - It reuses what exists. Re-nudging is the existing nudge budget and one-shot
latch pattern; the value→text render and the
AgentResultinbox delivery are 260622’s. The only genuinely new pieces are the headlessresultsink (a faithful value) and the gate’sinteractivediscriminator.
Consequences — and what this does not do
reply-as-done is orchestrate-and-abandon, not collect. With the headless root atpark_when_idle = false(session.rs:313), it cannot await a child’sAgentResultbefore replying.replygives a clean termination and lets the root abandon any still-running children — they are cancelled at the hard-terminate (registry clear), and their partial usage to that point is already counted at the emit-seam usage meter (the accounting sibling of recording-follows-the-event). A headless root that needs a child’s result still requires a registry-empty wait — an orthogonal decision, not this one.- Stragglers at root reply.
replyhard-terminates after the tool batch lands (260622); for the root that ends the drive loop with children possibly live. Those are cancelled there, their tokens counted to the moment of cancel.
Alternatives considered
- Keep
replyroot-withheld; improve the headless scrape instead. Rejected: the scrape is the bug. Any positional rule loses the same reports 260622 enumerated; the headless root deserves the same deliberate artifact, not a better guess. - Always stringify the headless
result. Rejected: double-encodes a structured reply so the harness re-parses JSON-in-a-string. Preserves Claude Code’s always-string shape at the cost of the feature. - Keep one-shot-nudge-then-
Empty(260622) for returning agents. Rejected: a single forgottenreplydiscards the agent’s work, and for the headless root yields a confident-looking fragment in place of the answer. Bounded enforcement plus honest failure is the better trade now thatreplyis the sole return. - Forced
tool_choicereply after N retries. Rejected, as in 260622: forcing a call mid-confusion corrupts the payload. Bounded re-nudging within budget is the enforcement; the step/budget cap is the backstop. - Validate the reply string as JSON, fail if it does not parse. Rejected: re-creates hand-serialized JSON and punishes a markdown answer containing a brace. Conformance is checked against a requested schema (deferred), not against free prose.
- A typed output schema per returning agent, now. Deferred, as in 260622 — the string/structured render rule covers both shapes without validation, and this decision leaves the hook for it.
See also
agent-reply-tool (the contract this extends),
async-agent-tool (the async worker and
AgentResult delivery seam),
recording-follows-the-event (the
emit-seam recording principle the run usage meter mirrors for accounting),
turn-ends-ready, agent,
tools, shell-eval (the value→text
render this reuses), and docs/SPEC.md §13.