Repository navigation
Conversation
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.
gloryfromca
left a comment
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
- The provider says it withheld the answer, and the loop drops the signal. Anthropic
stop_reason: "refusal"is not in_FINISH_REASONSand 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 mapsrefusal,SAFETY,RECITATION,PROHIBITED_CONTENT,guardrail_intervenedand others to"content_filter"(litellm/litellm_core_utils/core_helpers.py:68-105), so Gemini safety blocks and Bedrock guardrails take the same path. - The adapter reports
"stop"although the upstream never sent a terminal event._consume_sse(Codex and OpenAI Responses) starts atfinish_reason = "stop"(openai_codex_provider.py:435) and only overwrites it onresponse.completed/response.done(:492-494), so an empty 200, keep-alives only, a stream that ends beforeresponse.completed, or a JSON error body at 200 all come back as an empty"stop". The Anthropic non-stream parse maps a missingstop_reasonto"stop"(:665 via :701). LiteLLM's non-stream exit returnsfinish_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.
There was a problem hiding this comment.
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.
|
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.
|
… 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).
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 ofREASONING_EFFORT_LADDER, and the Codex backend refusesminimalon 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:
AnswerlessTurnErrorthat names which, even on its last iteration. Reasoning counts in any form, including a billedreasoning_tokenscount when the text is withheld.Key decisions:
highandlow, 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.lowinstead of the ladder's floor, so compaction no longer sendsminimaleither. A refused summary is not asked again; the head is elided.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.after_sendchain still runs, withoutbound_contentNone, 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, butafter_sendfires where it did not, and a hook can be handed None.after_sendwith 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.agents.defaultssettingsemptyRecoveryEnabled,postToolEmptyMaxNudges,thinkingPrefillMaxRetriesandemptyContentMaxRetries(a config that still carries them loads unchanged, and they are dropped the next time it is saved), andreasoning_wire_keysfrom the providers.Checked and deliberately left as is:
raven/contractssits exactly at its 3700-line ceiling after this change.TurnPolicy.empty_recoverykeeps 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_prefillhas no caller left. Removing it changes the contract tier, which means aCONTRACTS_VERSIONbump; left for a change that bumps it anyway.tests/test_agent_loop_empty_recovery.pykeeps its name. Renaming a test file needs the owner's word under AGENTS.md section 5.4.Type
Verification
Full Python suite at the head, in a venv synced with every extra:
pytest -q -p no:cacheprovider -n 4gave 3 failed, 34193 passed, 181 skipped, 2 xfailed. The three fail the same way onmainon 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) andtest_subagent_node_runtime.py::test_what_cannot_be_read_names_nothing(run as root, which reads the unreadable file anyway).vitest runon 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_sendfire 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 checkandruff format --checkon 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.pyandscripts/check_large_files.pyover 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.pyover the branch: exit 0, and exit 1 on a control commit titledUpdate stuff..scripts/check_pr_body.pyon 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
highorlowinstead of failing with the 400; the TUI shows no empty row for such a turn. Product hooks that readoutbound_contentas a string must handle None atafter_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.
Related Issues
N/A