A subagent that returns before it answers
The harness already had a background-task manager and a soft-interrupt mechanism; both were used by background shell commands. The Always patch adds a fourth variant to the subagent tool's output mode and wires it into the existing machinery. The doc comment on the new variant is the whole design in four lines:
“Spawn the subagent on the global BackgroundTaskManager. Returns a task_id immediately; the parent's turn ends without waiting. Completion is auto-injected into the parent's next turn via the BackgroundTask soft interrupt path (Bus → dispatch_background_task_completion → Injection D).”jcode/src/tool/task.rs, lines 75–78 — verbatim
execute_background function and one branch in the
notification formatter. Everything else is machinery that already existed to make
bash commands run in the background, reused unchanged.| notify | wake | Effective | What happens |
|---|---|---|---|
false | false | (false, false) | Task runs, files are written, nothing is delivered. This is spawn()'s caller's choice, not a default. |
true | false | (true, false) | Attached clients get a notification card. The model never learns the task finished. |
false | true | (true, true) | Wake silently implies notify. Requesting a wake without a notification is not expressible. |
true | true | (true, true) | What background subagents use. Notification fans out and the host is woken. |
The 256,000-byte branch is the whole point, and it is untested
Ordinary background tasks deliver a 500-character preview, which is useless for a subagent whose
entire value is its answer. So format_background_task_notification_markdown special-cases
tool_name == "subagent", reads the output file synchronously with
std::fs::read_to_string, and inlines the whole thing if it is non-empty and
s.len() <= 256_000. The literal appears exactly once in the entire Rust tree.
src/message/tests.rs has five tests for this function and
grep -n subagent against it returns nothing, so neither the branch nor the cap has
coverage. Neither does execute_background itself — no test in the tree constructs
SubagentOutputMode::Background.
// Subagent background completions need the FULL final answer inlined // (not the 500-char preview) — the host LLM must see the complete result // to integrate it on its next turn. Falls back to preview if the file // is missing, empty, or exceeds the cap. let full_subagent_output = if task.tool_name == "subagent" { std::fs::read_to_string(&task.output_file) .ok() .filter(|s| !s.is_empty() && s.len() <= 256_000) } else { None };
The design that was thrown away
By the time those text_delta events reach the voice stack, the hard problem is no longer
producing them. It is reading them. The chat client only reads its socket from inside a
chat() call, so events emitted between turns queue in the kernel buffer and are then
misattributed to whatever turn reads next.
The first attempt merged ambient reading into the chat client's connection pool: one socket per session, a dedicated reader thread, and demultiplexing by message id. The documentation still describes that design. The module that replaced it opens with a post-mortem of why it did not work:
README.md and ARCHITECTURE.md describe, in ten places between them. The
right one is what runs. The decisive observation is on the right: the agent already fans events out
to all subscribed connections on a session, so a second connection needs no cooperation from
the first.“Verified empirically (2026-05-16) that jcode-server fans outvoice-daemon/ambient_listener.py, lines 22–26 — verbatim. The script it cites as evidence,text_deltaevents from background-subagent completions to ALL subscribed connections on the session, not only to the connection that originally sent the/subagentmessage.”
scripts/test_passive_subscriber.py, is not in the tree.
Deciding what is worth saying out loud
Reading the events is not enough. A woken host model produces the same stream it produces for any turn — including planning chatter that reads badly when a speaker says it into an empty room. The gateway therefore applies three independent controls before anything is synthesised.
Suppression
The callback's first act, after the debug-only soft_interrupt_injected marker, is to
check state.foreground_turn_active. If a user-initiated turn is in flight, the ambient
buffer is cleared, the chunk index is reset to zero, and the event is dropped. The foreground path is
already synthesising those same deltas via _send_turn_result; without this check the user
would hear every sentence twice. Notably it is not gated on state.is_speaking,
because ambient synthesis sets that flag itself and would suppress its own continuation.
The narratable filter
_ambient_text_is_narratable() lower-cases the normalised text and rejects it if it
contains any of eleven substrings. The list is a direct transcript of what a model sounds like when it
is thinking aloud:
_AMBIENT_INTERNAL_MARKERS = (
"<think", "analysis:", "reasoning:",
"the user is asking", "the user asked",
"i need to", "i should", "we need to",
"let me inspect", "let me check", "tool call",
)
"i should" anywhere silences the entire flush — including the useful sentence it was
attached to. Crude, and cheap, and it is the only thing standing between the user and a speaker
reading out a chain of thought.Chunking, which is off by default
This is the detail most likely to surprise someone who has only read the architecture document.
ALWAYS_AMBIENT_STREAM_TTS defaults to false. With that default, a
text_delta is appended to the buffer and the callback returns immediately — no
ambient_text_delta frame, no sentence-boundary flush, no size-based flush. The buffer is
merely trimmed to its last 8,000 characters. Everything is spoken in a single chunk when
message_end arrives.
Default (ambient_stream_tts = false) | Streaming enabled | |
|---|---|---|
| Text to browser | one frame at message_end | one ambient_text_delta per token |
| First audio | after the whole narration completes | at the first sentence boundary |
| Buffer cap | 8,000 chars, trimmed from the front | 320 chars, forces a flush |
| Chunk index | always 0, then reset | increments per flush |
| Narratable filter | applied to the whole narration | applied per chunk |
One constant is defined and never read
_AMBIENT_IDLE_FLUSH_S = 1.2 sits at gateway/server.py:2847 under a
five-line comment explaining that it catches short narrations without terminal punctuation.
grep finds exactly one occurrence: the definition. There is no timer in the shipped
listener — the idle-flush behaviour belonged to the abandoned pump, which polled with a 1.2-second
readline timeout. The constant is a fossil of Figure 9's left-hand design, as is
AMBIENT_FORWARDED in the worker, which is defined at lines 81–87 and referenced
nowhere.
Why the browser keeps ambient audio separate
Ambient audio and foreground audio arrive on the same WebSocket as the same
audio_chunk message type, distinguished only by an ambient: true flag the
gateway sets. The client keeps two arrays and a strict priority:
foregroundAudioQueueRef— cleared whenever a chunk withindex === 0arrives, so a new answer always cancels the previous one.ambientAudioQueueRef— appended to, never cleared by foreground activity.playNextAudio()shifts from the foreground queue first and only falls through to the ambient queue when it is empty. On a playback failure the item is pushed back onto the queue it came from.
The invariant that falls out of this is the one the feature needs: a narration that begins while you are silent will not be destroyed by you then speaking — it is deprioritised, not discarded. Meanwhile the suppression check in the gateway ensures no ambient audio is generated during that foreground turn in the first place, so the two mechanisms bracket the same hazard from both ends.
Where the ambient listener is started
_start_ambient_pump() is called from inside _send_turn_result(), after the
agent has revealed the session id. It cannot run earlier: resume_session needs an id
that does not exist until the first turn completes. The function is idempotent, returns immediately
if a listener is already bound to the same session, and tears down and replaces the listener when
the session id changes. Its name is a leftover — the docstring says so — from the design in
Figure 9's left panel.