Skip to content

feat(*): let a model end its turn without a reply - #1052

Closed
LivXue wants to merge 5 commits into
mainfrom
feat/allow_empty_model_reply
Closed

LivXue wants to merge 5 commits into
mainfrom
feat/allow_empty_model_reply

Conversation

@LivXue

@LivXue LivXue commented Oct 21, 2026

Copy link
Copy Markdown
Member

Summary

A turn on a Codex model failed with HTTP 400: Unsupported value: 'minimal' is not supported with the 'gpt-6.1-sol' model. The empty-reply recovery had asked the model again at the bottom of REASONING_EFFORT_LADDER, and the Codex backend refuses minimal on every model. Behind the 400 sat the recovery itself: a model that says nothing has given its answer, and the loop should not re-ask, nudge or fail it.

What a model's last step means now:

  • text, or a tool call: never an empty reply;
  • nothing at all (no text, no tool call, no reasoning, not cut at the output ceiling): the turn ends as a Silent Turn. Nothing is filed as a failure, and nothing is asked again unless a product budgets a rerun (Raven-Research does, see below);
  • reasoning with no body, or cut at the output ceiling with no text: the model spent its budget thinking, so the call is asked again one rung lower, each rung once a turn. With no rung left the turn fails with an AnswerlessTurnError that names which, even on its last iteration. Reasoning counts in any form, including a billed reasoning_tokens count when the text is withheld.

Key decisions:

  • The step-down rungs are high and low, the two levels nearly every reasoning backend takes as sent. A turn that named an effort steps down from it; one that named none walks both, high then low, since the backend's own default cannot be seen from the loop. The ladder itself keeps its six rungs.
  • The window's head summary asks for low instead of the ladder's floor, so compaction no longer sends minimal either. A refused summary is not asked again; the head is elided.
  • Sub-agent and DAG lanes are unchanged.
  • Every surface a reply used to end now ends without one: the gateway tells the channel the turn is over (end_turn), so Telegram, Discord, Matrix and WeChat stop typing; a silent cron run is recorded as done without waking heartbeat; the TUI adds no blank row.
  • On a Silent Turn the after_send chain still runs, with outbound_content None, so hooks file their bookkeeping: the oncall hold on a pending wake reaches the ACP client, and the research flow records the turn. Text a hook composes there is sent as the reply, so a deck the ppt engine published that turn is delivered. The Sentinel nudge injector is not asked, so its nudge waits for the next reply. Post-turn learning runs only on a turn with a reply. The hook surface version moves to 4: same vocabulary, but after_send fires where it did not, and a hook can be handed None.
  • Raven-Research keeps reading a Silent Turn as a turn with no answer, as a deliberate product exception: its dead-end rerun runs the turn again from the question, and its terminal salvage then writes an answer. Only when the salvage comes back with nothing does such a turn reach after_send with no reply, and the research trail the flow appends to every research turn then goes out on its own; that is kept too. CONTEXT.md records the exception under Silent Turn.
  • Saying nothing on the turn's last iteration is still a Silent Turn, not an exhausted turn that gets a wrap-up. Asking again a rung lower spends no iteration, so a step-down owed on the last iteration is still asked, and the rung a turn last stepped down to is kept across a product's rerun of the turn.
  • A Silent Turn that called no tool writes no assistant message. What its hooks file is then kept on the session, at where the turn began, and Response Meta reads it there, so an oncall hold still reaches the ACP client. The next call folds that turn's question into the one after it: some providers refuse two user messages in a row, and would refuse every call for the rest of the session. History keeps both messages; only the payload folds them. A compaction summary and the wrap-up instruction keep their own messages: the summary sits behind a first user message that must stay byte-identical for the cached prompt prefix, and the instruction is the harness's ask, not part of the user's question.
  • Removed: the prefill and nudge recovery modes, the four agents.defaults settings emptyRecoveryEnabled, postToolEmptyMaxNudges, thinkingPrefillMaxRetries and emptyContentMaxRetries (a config that still carries them loads unchanged, and they are dropped the next time it is saved), and reasoning_wire_keys from the providers.

Checked and deliberately left as is:

  • The design engine only decorates a reply, so it adds nothing to a Silent Turn; the ppt engine announces a deck it published that turn.
  • A model with no reasoning that is cut at the output ceiling with no text gets the two step-downs, which cannot help it, and then fails. Telling it to split a payload too large for one call was the removed output-limit nudge's job.
  • raven/contracts sits exactly at its 3700-line ceiling after this change.
  • TurnPolicy.empty_recovery keeps its name although it now carries only the model-error waits and the image budget; renaming it touches every caller and is better done alone.
  • LLMProvider.supports_assistant_prefill has no caller left. Removing it changes the contract tier, which means a CONTRACTS_VERSION bump; left for a change that bumps it anyway.
  • tests/test_agent_loop_empty_recovery.py keeps its name. Renaming a test file needs the owner's word under AGENTS.md section 5.4.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

  • Full Python suite at the head, in a venv synced with every extra: pytest -q -p no:cacheprovider -n 4 gave 3 failed, 34193 passed, 181 skipped, 2 xfailed. The three fail the same way on main on the machine that ran them: test_install_script.py::test_resolve_node_dir_answers_each_case_it_exists_for (an npm elsewhere on PATH), test_knowledge_parser_excel.py::test_a_legacy_workbook_is_converted_and_read (a LibreOffice conversion that writes no file there) and test_subagent_node_runtime.py::test_what_cannot_be_read_names_nothing (run as root, which reads the unreadable file anyway).

  • vitest run on the two TUI test files this touches: 48 passed.

  • Every new test failed before its change and passed after it. Twenty mutants, each removing one new guard, are each caught by the test written for it: the after_send fire on a Silent Turn, the separator, the whitespace check, the nudge skip, the cap exemption, the step-down that spends no iteration, the turn-wide rung, the session record with its scope and its overwrite, and the payload fold with its two exceptions.

  • Probes: a config carrying the four removed keys loads through load_config; the ppt engine's own hook composes its deck lines when handed no reply (now a test).

  • ruff check and ruff format --check on the 59 changed Python files: clean. lint-imports: 10 kept, 0 broken. ty check raven evolver agents plugins-dist scripts: all checks passed.

  • scripts/check_source_language.py and scripts/check_large_files.py over the branch range: exit 0; the language check exits 1 on a control that adds one CJK comment. git diff --check: clean.

  • scripts/check_commit_messages.py over the branch: exit 0, and exit 1 on a control commit titled Update stuff.. scripts/check_pr_body.py on this text: exit 0, and exit 1 with an em-dash appended.

  • Relevant tests pass locally

  • Relevant lint / type checks pass locally

  • User-facing docs or screenshots are updated when needed

Risk

User-visible: outside Raven-Research, a model that says nothing now produces no reply and no error; a Codex turn that stops while thinking is asked again at high or low instead of failing with the 400; the TUI shows no empty row for such a turn. Product hooks that read outbound_content as a string must handle None at after_send; the in-tree ones (participant, oncall, research, design, ppt, the agent scaffolds) already do.

Rollback: revert this commit. Nothing is migrated, and a config written for either side loads on the other.

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

Related Issues

N/A

An empty model reply is no longer one thing. A model that said nothing
has given its answer: the turn ends without a reply, and nothing
re-asks, nudges or fails it. The prefill and nudge recovery modes are
removed, and so is the fail-on-empty that ended such turns as errors.

What the model's last step means now:
- text, or a tool call: never an empty reply;
- nothing at all (no text, no tool call, no reasoning, not cut): the
  turn ends silently, with nothing delivered and nothing filed as a
  failure;
- reasoning with no body, or cut at the output ceiling with no text: the
  model spent its budget thinking, so the call is asked again one rung
  at a time, each rung once a turn. With no rung left the turn fails,
  even on its last iteration, with "The model stopped while still
  thinking, before producing any reply." or "The model reached its
  output limit before producing any reply." Reasoning counts in any
  form, including a billed reasoning_tokens count, read through
  token_count.

The rungs are high and low, the two levels nearly every reasoning
backend takes as sent. The old retry walked REASONING_EFFORT_LADDER down
to its floor, minimal, which the Codex backend refuses on every model
with HTTP 400. A turn that named an effort steps down from it, placed on
that ladder; one that named none walks both rungs, high then low, since
the backend's own default cannot be seen from the loop. The ladder
itself keeps its six rungs.

The window's head summary now asks for low rather than the ladder's
floor, so compaction no longer sends minimal either. That is the same
400: a refused summary is not asked again, and the head it was meant to
carry is elided instead.

Sub-agent and DAG lanes are unchanged.

Every surface a reply used to end now ends without one. The gateway
tells the channel the turn is over (end_turn, run off the lane and
settled within the delivery grace at teardown), so Telegram, Discord,
Matrix and WeChat stop typing. A silent cron run is recorded as done
without waking heartbeat. The TUI adds no blank row. The after_send
hooks and post-turn learning do not run. A stream that stays empty with
transport-failure evidence still fails as the transport failure it is.

The four agents.defaults empty-recovery settings are removed; a config
that still carries them loads unchanged. reasoning_wire_keys is removed
from the providers.
A turn the model ended without a reply skipped the after_send chain, so
what its hooks do there was lost with the reply: the oncall flow's hold
on a pending wake never reached the ACP client, the research flow did
not record the turn, and a deck the ppt engine published that turn was
never announced. The chain now runs on such a turn too, with
outbound_content None.

Text a hook composes there is sent as the turn's reply, and the turn is
then not silent. The separator a hook put in front of its text is
dropped from that reply, since there is no text of the model's own for
it to separate; the tail handed to a streaming caller keeps it, because
text the model said beside a tool call may already be on screen.
Whitespace alone leaves the turn silent. A research turn that searched
and then said nothing therefore sends the research trail the flow
appends to every research turn.

The legacy response modifier (the Sentinel nudge injector) is not asked
on a turn with no reply: a nudge rides on a reply, so it stays queued
for the next one instead of going out alone. Post-turn learning still
runs only on a turn that has a reply.

The hook surface version moves to 4. Its vocabulary is unchanged, but
after_send now fires where it did not, and a hook can be handed None.
Fixes from a self-review of the two commits before this one.

A model that said nothing on the turn's last iteration had its turn
wrapped up as exhausted: a canned "maximum number of tool call
iterations" reply went out and the turn was filed as interrupted. The
loop now remembers that the model ended the turn, and the exhaustion
wrap-up skips it.

A step-down owed on the last iteration was never asked: the loop ran out
of iterations first and wrapped the turn up, instead of asking again at
low and then failing. Asking again repeats a call that did no work, so
it now spends no iteration, as the model-error waits already do. The
rung a turn last stepped down to is kept for the whole turn, so a
product's rerun of the turn asks at the turn's own effort and does not
walk high and low a second time.

A silent turn that called no tool writes no assistant message, so what
its hooks filed under observers had nothing to ride on, and an oncall
hold never reached the ACP client. The loop now files such a record on
the session, at where the turn began, and Response Meta reads it back
there under the same scope as the messages. Every turn that completes
writes it, None included, so an undone turn's record cannot answer the
turn after it.

The same turn leaves its question with nothing after it, so the next
request carried two user messages in a row, which some providers refuse
for the rest of the session. The payload now folds adjacent plain user
messages into one at the call seam and in the wrap-up request. History
keeps both, and a message carrying one of the loop's own marks is left
alone.

Stale text: the cron docs no longer call an undelivered result a silent
completion, three places no longer say chat_with_retry carries an
empty-response retry (it retries transient provider failures), and the
after_send row in agents/BUILDING.md names the None case.
Owner ruling: Raven-Research keeps reading a Silent Turn as a turn with
no answer, as a product exception. Its dead-end rerun runs the turn
again from the question, and its terminal salvage then writes an answer.
Only when the salvage comes back with nothing does the turn reach the
after_send chain with no reply, and the research trail the flow appends
to every research turn then goes out on its own; that is kept as well.

CONTEXT.md now says so under Silent Turn, and its Avoid line names the
two places that still see such a turn as one with no answer. The
terminal_answerless contract and the loop's seam comment list a model
that said nothing among the exits the seam fires for.
The fold of adjacent user messages from the commit before reached two
messages that ride on their own on purpose.

A compaction summary follows the first user message, which stays
byte-identical so the cached prompt prefix ahead of the summary survives
every compaction. Folding the summary into it rewrote that message at
each compaction. The fold now leaves a message that opens with the
compaction marker alone, as it already left the loop's marked messages.

The wrap-up after an exhausted turn sends its instruction as the last
user message, and the model is meant to read it as the harness's ask,
not as part of the user's question. The wrap-up now folds the
conversation first and appends the instruction after it.
@LivXue
LivXue requested review from 0xKT and gloryfromca October 21, 2026 11:35

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers; this can merge as far as I am concerned.

I reviewed the full github/main...HEAD diff and the surrounding runtime paths for silent replies, reasoning-only failures, hook finalization, persistence, provider transport-failure classification, channel lifecycle cleanup, cron behavior, and TUI completion. I also checked the branch history, compatibility implications, test changes for weakening, and the repository rules and Runtime/TUI architecture constraints in AGENTS.md, CLAUDE.md, CONTEXT-MAP.md, and CONTEXT.md.

Verification: uv run pytest over every changed tests/test_*.py file passed (920 tests), and the two changed TUI test files passed (48 tests). git diff --check is clean. The focused CI checks visible at review time were green; two unit shards were still running.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: an empty reply ends the turn as a Silent Turn even when the provider withheld it or never finished it, so refusals, content filters and upstream errors now vanish without a failure.

The silence decision at raven/agent/loop/turn_path.py:1778-1781 reads text, tool calls and thinking only: if clean is None: if (failure := stopped_in_thinking(response)) is None: said_nothing = True; break (stopped_in_thinking, raven/agent/loop/recovery.py:134-150, looks at truncated, thinking and reasoning tokens). Nothing on that path asks whether the upstream actually ended the reply normally, and two kinds of empty reply that are not the model's choice reach it:

  1. The provider says it withheld the answer, and the loop drops the signal. Anthropic stop_reason: "refusal" is not in _FINISH_REASONS and arrives as finish_reason "refusal" (anthropic_messages_provider.py:72-78, :665); Azure passes "content_filter" through with null content (azure_openai_provider.py:254-256); LiteLLM 1.85.0 maps refusal, SAFETY, RECITATION, PROHIBITED_CONTENT, guardrail_intervened and others to "content_filter" (litellm/litellm_core_utils/core_helpers.py:68-105), so Gemini safety blocks and Bedrock guardrails take the same path.
  2. The adapter reports "stop" although the upstream never sent a terminal event. _consume_sse (Codex and OpenAI Responses) starts at finish_reason = "stop" (openai_codex_provider.py:435) and only overwrites it on response.completed / response.done (:492-494), so an empty 200, keep-alives only, a stream that ends before response.completed, or a JSON error body at 200 all come back as an empty "stop". The Anthropic non-stream parse maps a missing stop_reason to "stop" (:665 via :701). LiteLLM's non-stream exit returns finish_reason or "stop" (litellm_provider.py:1141), although its own comment at :1103-1105 says a provider that sent no finish reason "never said this call ended normally".

Measured offline: a stub Anthropic /v1/messages on 127.0.0.1 port 0, the real AnthropicMessagesProvider, and AgentLoop.run_turn as the gateway calls it, for Origin.USER and Origin.CRON, on main e1c44e5 and on this PR merged onto it:

upstream reply main e1c44e5 this PR
content: [], stop_reason: "refusal" 2 calls, then AnswerlessTurnError: the turn fails visibly 1 call, TurnOutcome(explicit_reply=False), nothing emitted
HTTP 200 with {"type": "error", "error": {"type": "overloaded_error", ...}} 2 calls, then AnswerlessTurnError 1 call, TurnOutcome(explicit_reply=False), nothing emitted
control: text hello there, end_turn reply delivered reply delivered

The code follows the Empty Reply entry this PR adds (CONTEXT.md:785-788: a reply with no text, no tool call, no reasoning and not cut "is a model that said nothing"), and that definition is where the defect sits: the Silent Turn entry (:801-802) rests on "the model said nothing", but a refusal or a filter is the provider removing the output, and a missing terminal event is the transport, not the model. Neither case is weighed in the commit messages. So a user whose message tripped a refusal or a content filter, or whose provider answered 200 with an error, sees the typing indicator stop and nothing else. The turn is not filed as a failure, and a cron run is recorded as done (raven/core/cron_stack.py:553-557 raises only for TurnFailed or no outcome). The commit message of d3aaf07 keeps an empty stream with transport-failure evidence a failure; here the evidence is in finish_reason or in the missing terminal event, and it never reaches the decision. Main turned every one of these into a visible failure.

A direction, which I have not tested: count an empty reply as the model's silence only when the upstream itself sent a normal finish (stop / end_turn), and fail it visibly otherwise, naming the reason; and have the adapters stop substituting "stop" when no terminal event arrived (the stream path already carries finish_synthesized, raven/providers/streaming.py:237), so the loop can tell the two apart.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: empty non-normal finishes must not be treated as intentional silence.

I independently reproduced the current loop behavior with LLMResponse(content=None, finish_reason=...) for refusal, content_filter, and unknown: each case made one provider call, returned no reply, and persisted only the user row. The relevant suites still pass (127 tests), so they do not cover this distinction. This invalidates my prior clean stance; the runtime needs to preserve the difference between a normally terminated empty reply and a withheld or unterminated response before this can merge.

@0xKT

0xKT commented Oct 21, 2026

Copy link
Copy Markdown
Member

Not a blocker -- the rest of what acceptance measured on 32dbb0b merged onto main e1c44e5; the blocking item is in the thread on raven/agent/loop/turn_path.py. I re-ran each item below myself, on extracts of this PR merged onto main and of main, with stub providers only.

Severity of this one finding, not a verdict on the pull request. Any blocking findings from this pass are review threads on the changed files; GitHub renders those collapsed, as a file name with no text.

  1. Raven-PPT: a build that goes silent mid-deck now ends with nothing. The ppt participant's unfinished-deck guard relied on the loop asking again after an empty reply that followed tool calls (plugins-dist/ppt-engine/raven_ppt/plugin/hook.py:497 now says the loop "settles first"); the loop settles that reply at turn_path.py:1778-1781, before after_iteration, so the guard is never asked. The real ppt hook through the real loop, scripted provider: a turn that started a deck (deck/state exists, nothing published), made a tool call, then returned an empty reply. Main: the post-tool nudge got "I will continue building the deck now." (5 calls). This PR: out=None after 2 calls -- no deck, no message, no failure. (Published-then-silent gets better: the hook now announces the deck where main failed the turn.) Research has an owner ruling to keep treating such a turn as one with no answer; ppt may need the same decision.

  2. The user-message fold misses a turn the runtime opens. After a silent user turn that called no tool, the next request of a CRON or SUBAGENT turn carries two user messages in a row (roles ['user', 'user']), because _plain_user (raven/agent/loop/_shared.py:393-400) leaves alone any message with an underscore key and every runtime-origin turn is stamped with _origin (turn_path.py:2299-2306). A user follow-up folds as intended (['user']). Main never produced the pair, since the failed turn left an assistant marker between the two questions. That is the shape f79e5e8 says some providers refuse, and an oncall wake after a silent "anything new?" is exactly it.

  3. A silent turn's session-level observer record outlives undo and /new. rowless_turn_observers is written after the turn's try block (turn_path.py:2647-2654), so a turn that raises leaves the previous record in place, and undo_last_turn() / clear() reset the messages but not this key. A hook files an ACP hold on silent turn A; then undo (or /new); then turn B fails (stopped while thinking, or a provider error): AcpMethods._turn_meta for B returns A's raven.holdTurn ("turn A's wake"), so the failed prompt is held open on a wake it never armed. Controls: B answering reads null, and without the rewind the at scope filters it out. The output-limit marker a few lines above is written ahead of the raise for this same reason.

  4. Azure drops billed reasoning tokens, so the same empty reply is classified differently by provider: azure_openai_provider.py:242-249 keeps only prompt, completion and total tokens, while recovery.py:148 now reads reasoning_tokens to tell "stopped while thinking" from silence. On Azure such a reply ends as a Silent Turn; through LiteLLM or the Responses adapter it steps down and then fails with "The model stopped while still thinking".

  5. end_turn is not declared on the Channel paper. It is a no-op on ChannelBase (raven/channels/base.py:54) reached by getattr(channel, "end_turn", None) (raven/gateway/spine.py:221), while raven/contracts/channel.py:5-9 says optional behaviour is a Supports* protocol a channel opts into and that there is no base class to subclass. Nothing breaks today, since every in-tree adapter subclasses ChannelBase, but a channel built from the paper is never told the hook exists.

  6. Contract-tier loose ends.

    • FACTORY_LOOP_SURFACE_VERSION = 4 is not pinned: with pinned[4] = pinned[3] (tests/test_agent_hook_contract.py:741), setting it back to 3 still passes tests/test_agent_hook_contract.py and tests/test_contracts_two_tier_ledger.py (54 passed), while 5 fails (1 failed). An assert lh.FACTORY_LOOP_SURFACE_VERSION == max(pinned) would hold this bump in place.
    • supports_assistant_prefill stays on the frozen LLMProvider paper and in the adapters, but with the prefill recovery removed nothing acts on its answer; only the resolving, per-model, lazy and rotor wrappers pass it along.
    • The after_send rows this PR edited in agents/BUILDING.md:137 and raven/templates/agents_scaffold/README.md:76 still say the phase fires "after the outbound text is sent"; the paper (raven/contracts/loop_hooks.py:370-371) says "before it is sent as the reply", and on a silent turn text a hook composes there becomes the reply. The paper's observer note (loop_hooks.py:149-157) still says the stash is filed on the turn's last substantive assistant message, which a silent turn without a tool call does not have.

@LivXue LivXue closed this Oct 22, 2026
0xKT pushed a commit that referenced this pull request Oct 28, 2026
… silence (#1092)

## Summary

Supersedes #1052, which was closed unmerged. That PR ended a turn
without a reply whenever the model returned nothing. After a survey of
how other agents handle the same replies, empty and abnormal replies now
follow Hermes Agent, and Raven keeps its reasoning-effort step-down only
for a reply that stopped while still thinking. This also ends the Codex
HTTP 400 "Unsupported value: 'minimal'": the old empty-reply recovery
lowered the effort on every resend, down to `minimal`, which Codex
refuses. Neither recovery nor the in-turn compaction summary asks for
`minimal` any more; a session whose own effort is `minimal` still sends
it, and the compacting history strategy's checkpoint summary goes out at
the provider's configured effort.

Silence is now something the model says, not something it omits:

- A reply that is a silence marker ends the turn without a reply. A
  turn a person started honours only the bracketed `[SILENT]` the
  runtime teaches, as the whole reply (whitespace aside): such a turn is
  never taught a marker, so `Silent.` or `No reply.` there is the
  person's answer. That is stricter than Hermes, whose strict rule takes
  all four spellings. A proactive turn (cron, heartbeat, Sentinel, a
  sub-agent report) uses Hermes' rules over all four (`[SILENT]`,
  `SILENT`, `NO_REPLY`, `NO REPLY`): the whole reply, at most 64
  characters, or the marker on the first or last line. An A2A turn,
  whose peer waits on the text, gets its reply back as written.
- The marker rules live in `raven/agent/context/silence.py`, outside
  the kernel. The kernel carries only the outcome: `TurnOutcome.silent`
  and the `silent_turn` notice.
- The cron reminder and the host's note on an unprompted sub-agent
  report teach the marker, in Hermes' words.
- Streaming holds back text that may still turn out to be a marker, so
  a silenced reply never flashes on screen. On a person's turn only a
  reply that opens with whitespace or `[` is held, and an inline think
  block streams as it arrives while what follows it is held; a
  proactive turn holds the whole call. The hold lasts across a stream
  cut and its continuation.
- The marker row is stored with `silent: true` and the turn ends with a
  `silent_turn` notice; the TUI and the web UI draw one muted line
  ("Raven chose not to reply."). ACP replay, the session list, the
  session export and the cron run list do not show it as a reply. A
  reply an `after_send` hook composes in place of the marker is
  delivered and stored as that reply.
- A channel stops its typing indicator (`end_turn`) when a turn a user
  started, or one a user's message was merged into, ends without a
  reply. A silent cron run is a success: it wakes nobody and does not
  count toward the silent-fire limit.

An empty reply is recovered by kind, decided by two pure functions
(`reply_kind`, `decide`):

- only thinking, no text (cut at the output limit or not): two
  prefills when there is thinking to re-feed, then the same step asked
  again at `high`, then `low` (only the rungs below the turn's effort,
  each once per turn), then the fallback models, then a failure;
- plain empty: resent after Hermes' jittered backoff, up to three
  times, or once when the call reported a cost at or above
  `emptyRetryCostThresholdUsd` (default 0.25 USD); two zero-output
  replies from one model stop the resends early. On a streamed turn
  the first resend goes out without streaming (see below);
- empty after a tool call: Hermes' nudge;
- cut at the output limit with neither text nor thinking: a
  continuation with max_tokens doubled each time (x2, x4, x8), held to
  the model's own output ceiling and to the room its prompt leaves;
- a refusal or a content filter (read off the finish, or off the
  `refusal` field, which litellm moves into `provider_specific_fields`):
  never resent; one fallback model, whose answer opens a step of its
  own, then a failure that quotes the model's explanation;
- fallback models are tried once each, and a switch holds for the rest
  of the turn.

Failures are worded after Hermes, per kind, and `no_output` (on
`TurnFailed` and `AnswerlessTurnError`) marks a turn whose last reply
was empty. A transient `llm_retry` notice says when a resend is
waiting.

A stream that dies after showing text is continued (up to three times,
stitched into the same reply). If it stays cut, or the turn runs out of
iterations or time while a continuation is owed, the partial is
delivered with a note that it is partial, status `interrupted`, instead
of failing the turn. A cut is read before any marker, so a fragment that
happens to spell one is continued, and a stream that had already
received a refusal finish keeps it; the reply the turn ends on, the
joined parts with their closed think blocks taken out, is then read for
a marker like any other, and parts that join into nothing but a thought
take the thinking-only path an uncut thought takes. A continuation that
calls a tool keeps the words the reader saw on that call's row, as they
came. Providers now report the finish they actually got: `unknown` for a
missing one, `refusal` / `content_filter`, and an error for a 200
response that carries one. ACP reports a turn its own budget ended as
`max_turn_requests` (#1121); a cut stream delivered as a partial spent
no budget and stays `end_turn`.

Decisions beyond the design, so a reviewer does not rediscover them:

- Only a real mid-stream death is continued. The stream hand-back marks
  it `finish_reason="stream_cut"`; a reply that merely lacks a finish is
  delivered as is. Continuing on a missing finish would draw three
  continuations on every reply of a backend that never sends one.
- The output-limit continuation and the post-tool nudge keep Hermes'
  frame, but say that the cut left nothing and that the model may
  answer or make the call the task needs: Raven sends them only for a
  cut that produced nothing, which Hermes' wording does not fit.
- Once the message tool has replied in a turn
  (`MessageTool.sent_in_turn`: its send to the turn's own chat went
  out), an empty last reply that a nudge or a prefill does not mend ends
  the turn as answered, without the paid resends, step-downs and
  fallbacks; a refusal there ends it the same way. A marker after such a
  reply does not make the turn silent, so no `silent_turn` notice is
  sent.
- On a streamed turn, the first resend of a plain empty reply asks the
  same model without streaming, with no provider-side fallback, and its
  answer is shown through the same sinks and gates a stream's deltas
  pass; later resends stream again. A stream can come back empty where
  the plain call does not: the locked litellm 1.85 drops a streamed
  chunk that carries only a refusal, so a refusal an OpenAI-compatible
  model streams arrives as an empty stop, and the plain call reads it
  and hands it to the refusal path. A gateway whose streams fail is
  rescued the same way.
- Sub-agent and DAG lanes keep their own recovery; besides the note
  above, only the providers' finish reasons reach them (see Risk).
- `after_send` hooks still run on a silent turn, with
  `outbound_content=None`; `FACTORY_LOOP_SURFACE_VERSION` is now 4.

How a stored turn is read: the live turn files `replied_via_tool` on the
last assistant row a turn the message tool answered stores, and the
session list and the ACP replay read that flag from one place,
`raven/session/turn_shape.py`, instead of guessing from the calls they
can see. A send that failed or went to another chat answered nobody, so
both surfaces agree with the live view about which turn answered.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

At the PR head, on origin/main 06509f8 (Linux, 32 cores).

- The full Python suite: 36878 passed, 9 failed, 255 skipped, 2
  xfailed.

  ```
nice -n 19 uv run --frozen --all-extras pytest -n 4 -q -p
no:cacheprovider
  ```

  The same 9 fail on a clean archive of origin/main 06509f8 on this
  machine: five proxy tests in tests/test_config_update_providers.py,
  test_install_script, test_knowledge_parser_excel,
  test_subagent_node_runtime, and test_cli_agent_commands'
  empty-message-file test, whose error box is cut to the terminal
  width. They come from the environment, not from this branch.
- ui-web `npx vitest run`: 260 files, 4461 passed.
- ui-tui `npx vitest run --maxWorkers=2`: 145 files, 2275 passed, 13
  skipped.
- `make lint-python lint-imports lint-deps lint-types lint-ui lint-tui
  check-commits check-large-files check-source-language`: all exit 0.
  ui-tui `npm run lint:i18n` and `npm run lint:rpc`, and ui-web
  `npm run gen:check`: exit 0.
- The review round's fixes were each written test first, and each was
  checked by removing or inverting it and watching a test go red. An
  independent re-review repeated those mutants, probed the stream and
  marker cases through the real loop against the previous head, and
  fuzzed the stream gate over about 35,000 random two-part cut replies
  without finding a silent turn that showed its marker.
- The rebase onto 06509f8 replayed every commit unchanged; its one
  meeting point with main, #1121's budget-stop marker, is described in
  the summary. Earlier rebases met #1031 (compacting history), #1078
  (refused cancel), #1119 (the context ring a resume restores), #1047
  and #1114; what each needed is pinned by tests on this branch.
- Prompt text, by a paper exercise with fresh model readers, at an
  earlier head (no prompt text has changed since): a routine sub-agent
  report was answered with `[SILENT]` and a finished one with a real
  report; a plain one-shot reminder was still delivered; an hourly
  watch with nothing new stayed silent. With the text before this
  change, the two routine cases produced a message.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

What a user will notice: a reply that is only a silence marker is not
delivered; an empty reply is asked again, stepped down or switched to a
fallback before the turn fails, and fails in Hermes' wording; a cut
stream is continued and, if it stays cut, delivered as partial instead
of failing the turn.

Known gaps, each left as it is here:

- A continuation request that raises (a non-retryable error, a cancel,
  or a retryable error that outlasts the stream's own ladder, about
  105 s) fails the turn although the reader saw the partial.
- An error the provider raises mid-stream (LiteLLM raises its HTTP
  errors) is not switched to a fallback model: it is waited out and
  fails the turn, as before. A stall before any output, and an error
  that comes back as a response, do fall back.
- On a plain (non-streamed) call, a fallback the provider switched to
  on its own is not known to the loop, so a later empty-reply fallback
  can ask that model again.
- A refusal a fallback model then answers streams live in a step of
  its own, and only the fallback's answer is stored, so a reload shows
  the answer alone. Storing the refused words would need a row kind
  shown to the reader but never sent to the model, and none exists.
- When the message tool replied and the model then ended on a marker,
  a reload draws the muted line after the reply; on the web the reply
  then sits in the turn's folded steps.
- A silent turn the runtime opened is left out of an ACP session/load
  replay with its tool calls, as the session list leaves it out,
  unless the message tool replied in it.
- The cron rule applies to every proactive origin, including sub-agent
  result turns that are never taught the marker: such a reply whose
  first or last line is a bare "No reply" or "Silent" is not delivered.
- Silent cron runs never reach the silent-fire limit, so a recurring
  job that always stays silent is never paused, and each run adds about
  3.5 KB to its session file and curator traces until consolidation
  trims them. A job whose model always returns nothing keeps firing,
  visible only as lastStatus=error.
- A refusal an OpenAI-compatible model streams costs one extra call
  before it is handled as a refusal, and only the turn's first plain
  empty resend can read it: a fallback model is never resent to, so a
  streamed refusal from a fallback still reads as an empty reply. The
  unstreamed resend's answer is shown whole rather than as it is
  written, has none of a stream's idle watchdogs, and shows only the
  retry notice while a long-reasoning model works; a proxy that caps
  plain responses turns it into an error, which takes the usual error
  path. A test pins what litellm 1.85 passes on, so an upgrade that
  starts passing the words is noticed.
- The cost guard reads only a reported `usage.cost_usd`, so it does
  nothing on direct OpenAI, Azure, Codex or the Anthropic API unless a
  gateway adds the cost.
- The failure for a thinking-only reply quotes up to 500 characters of
  the model's thinking in the TUI and web detail; channels get the
  bounded 200-character text.
- `stream_cut` shows as the call's finish in the trace viewer.
- Sub-agent runs keep their own recovery, and with #1047 merged they
  read the providers' honest finish reasons this way: a refusal with
  text is the run's answer; a refusal, content filter or missing finish
  with no text is a blank reply, which gets the one wrap-up call and
  then fails as having no answer; a 200 response carrying an error fails
  the run as a failed model call, with no wrap-up. An empty stream whose
  prompt was never read is asked once more without streaming in a
  sub-agent's direct chat before it fails.
- Near the context window, an output-limit continuation asks for no more
  than the room its prompt leaves, as the first call did; a reply cut
  there with nothing in it is therefore usually cut again and fails
  after its continuations.
- A cut keeps its two scaffolding rows (the words so far and the ask
  to go on) in the window for the rest of the turn, so a continuation
  that calls a tool shows the model the partial twice, and a cut that
  joins into a bare thought is prefilled, stepped down or sent to a
  fallback after them: the re-ask reads as "continue the cut thought",
  and a model that goes on mid-thought has the thought's tail delivered.
  The stored history has neither row. Taking the rows out lost a hook's
  note and left the question autofill reading an old list.
- Three rare cut shapes still show or deliver part of a marker: a cut
  whose continuation is refused and then answered by a fallback shows
  the held marker in the refused step; a cut marker prefix whose
  continuation starts the marker over (`[SILEN`, then `[SILENT]`)
  delivers the fragment, as before; a hook rollback of the
  continuation's tool call drops the partial from that call's row, as
  before.
- A cron turn that took in a person's message and then fails with no
  output leaves that chat's typing indicator running until the next
  send.
- When a refused stop holds the lane, the stopped turn's late end is
  dropped, and with it any silent mark that turn set, so the next turn
  is not drawn as silent.
- Unchanged from main: the think-block pattern and the stream's opening
  hold are quadratic on very long runs of tags or whitespace.

Compatibility and rollback:

- #1031 (merged) also changes `raven/session/manager.py`: it adds a
  caller of `_scan_file` and changes neither its return shape nor the
  silent-turn rule.
- `after_send` hooks now also run on a silent turn with
  `outbound_content=None`; an out-of-tree hook that assumes a string
  must accept None.
- The new fields (`silent` and `replied_via_tool` on stored rows and on
  `TranscriptMessage`, `no_output` on failures, the `silent_turn` notice
  kind) are optional; an older Raven reading a session written by this
  one shows a marker row's text as a reply.
- New config key `agents.defaults.emptyRetryCostThresholdUsd`
  (default 0.25). Rollback is a revert of the squash commit; there is
  no data migration.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A. Supersedes #1052 (closed unmerged).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants