Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 43 additions & 31 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -782,32 +782,43 @@ judged without a tree, so a file the checkpoint would not store has no diff and
it goes out `withheld`, which nothing else may fill in.
_Avoid_: "shadow git" as the term — Checkpoint is the per-turn snapshot it produces.

**Empty-Response Recovery** (`agent/loop/recovery.py`):
The opt-in policy for when the model returns no text: re-feed its reasoning (PREFILL),
inject a nudge after a tool call (NUDGE), or plain RETRY — each bounded by
`RecoveryLimits`. A response with text in it, or recovery switched off, COMPLETEs;
spending every budget with nothing ever returned is FAIL, and the Agent Loop ends the turn
with status `error` and a sentence saying how many attempts went into it. Either way a turn
that reaches its end with no text raises `AnswerlessTurnError` rather than replying -- unless
one of its tools already put an answer in front of the reader (the message tool's
`sent_in_turn`, or an inline research answer the turn itself streamed).
PREFILL is asked of the provider first
(`LLMProvider.supports_assistant_prefill`): the Anthropic family rejects a trailing
assistant message while thinking is on, and through a gateway that rejection can arrive as
a stream that never yields a byte, so a provider that answers no never gets PREFILL -- the
same thinking-only turn takes NUDGE after a tool, else RETRY, and no request handed to it
ends on an assistant message. RETRY descends the effort ladder by what a rung *sends*, not by
its label: the provider is asked (`LLMProvider.reasoning_wire_keys`) and a rung whose request
this wire cannot tell apart from the one that failed is skipped -- when none is left, the
turn has nothing to change and FAILs rather than paying for the same call twice. A retry
after a turn cut at the output ceiling also carries `OUTPUT_LIMIT_NUDGE`, once per turn. The
descent changes what the request asks *for*; the nudge is the only thing that tells the model
its last turn was cut and that a payload too large to finish has to be split -- which no
effort rung can do, and which `Tool.truncation_hint` cannot reach because a turn cut before
any call produced no tool result to carry it.
_Avoid_: calling the whole mechanism a "nudge" — nudge is one of its modes; and reading
FAIL as a fourth mode — it is the answer given when there is nothing left to try;
`OUTPUT_LIMIT_NUDGE` rides RETRY rather than being the NUDGE mode.
**Empty Reply** (`agent/loop/recovery.py`):
A model reply with no text and no tool call. It is read for one fact -- whether the model stopped
while thinking. A reply with nothing in it (no text, no tool call, no reasoning, not cut at the
ceiling) is a model that said nothing: it is not asked again, and the turn ends as a **Silent
Turn**. A reply that reasoned and never reached a body stopped while thinking, and so did one cut at
the output ceiling: the call is asked again a rung at a time, high and then low -- below the effort
the turn named on `REASONING_EFFORT_LADDER`, or both from the top when it named none -- each rung
once a turn, a product's rerun of the turn included (`lower_reasoning_effort`). Asking again
spends no iteration, since the call it repeats did no work. With no rung left, the turn fails with an
`AnswerlessTurnError` naming which (`stopped_in_thinking`), even on its last iteration. Reasoning
counts in whatever form a provider reports it: a structured field, thinking blocks, an inline
`<think>` block, or a billed `reasoning_tokens` count when the text is withheld, as Codex withholds
it. A tool call is never an empty reply, and neither is any text.
_Avoid_: "empty reply" for a turn that called tools and then said nothing -- that turn ends as a
Silent Turn, not a failure; and reading a Silent Turn as a failed turn.

**Silent Turn**:
A turn that ends with no reply because the model said nothing (see **Empty Reply**). Nothing is
filed as a failure: the question and the turn's tool work are kept, and no assistant row is
written. The `after_send` chain still runs, with no outbound content, so the turn's hooks file
their bookkeeping (an oncall hold, a research record). Text a hook composes there -- a deck built
this turn -- is sent as the reply, and the turn is then not silent; a hook that only decorates a
reply, such as a queued nudge, leaves it for the next one. Post-turn learning does not run.
Saying nothing on the last iteration is still a Silent Turn, never an exhausted one that gets a
wrap-up. A Silent Turn that called no tool writes no assistant message at all: its hooks' record
is then filed on the session, where **Response Meta** reads it, and the next call folds its
question into the one after it, since some providers refuse two user messages in a row.
What a reply would have ended is ended without one: a channel is told the turn is over
(`ChannelBase.end_turn`) so a typing indicator stops, a cron run is recorded as done without
waking heartbeat, and the TUI adds no row.
A product may still read a Silent Turn as a turn with no answer, and Raven-Research does, by the
owner's choice: its dead-end rerun (`deadEndRetry`) runs the turn again from the question, and
its terminal salvage (`forceFinalize`, at the `terminal_answerless` seam) 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.
_Avoid_: calling it a failed turn; a failed turn raises `AnswerlessTurnError`. The
`terminal_answerless` seam and a dead-end rerun still see it as a turn with no answer.

**Model-Error Ladder** (`agent/loop/turn_path.py`, `RecoveryLimits.llm_error_retry_delays`):
The waits the Agent Loop spends on a model call that came back an *error* before it gives the
Expand All @@ -817,8 +828,8 @@ same ask goes out again — the messages are untouched, so nothing is appended f
call. The wait is announced as a `NoticeKind.LLM_RETRY` notice carrying the error *category*,
which is how a surface can say the runtime is still working rather than showing nothing for
minutes. A non-retryable verdict, or a spent ladder, ends the turn.
_Avoid_: "retry" unqualified — Empty-Response Recovery's RETRY is a different mechanism, for a
call that *succeeded* and returned no text; this one answers a call that failed.
_Avoid_: reading it as the step-down an **Empty Reply** takes -- that one asks again with less
thinking allowed; this ladder answers a call that failed, with the same ask.

**Call Record** (`contracts/llm_provider.py`, `providers/call_record.py`):
What the transport did on one model call, carried on `LLMResponse.call_record` and stored
Expand Down Expand Up @@ -908,8 +919,8 @@ those are emitted by the Spine worker, not a runner.
**AnswerlessTurnError**:
The exception a runner raises to say the turn it ran ended with no answer and that its message is
already the report a reader should see — the model call the loop gave up on, in the provider's words,
or the loop's when the provider gave none; or an empty-response recovery that spent its budgets, in the
loop's one-sentence account of the attempts.
or the loop's when the provider gave none; or a reply that stopped while thinking (see **Empty Reply**),
in the loop's own sentence.
`describe_failure` passes its text through unchanged (it arrives bounded from the layer that built
it), and the `turn_ended` marker that says so is filed before the failure leaves the loop. The marker
is worded twice, for its two readers: `turn_ended.reason` keeps the provider's account for whoever is
Expand Down Expand Up @@ -3181,7 +3192,8 @@ What an ACP agent attaches to its `session/prompt` response under `_meta`, the f
schema reserves for an agent's own metadata, kept on the run record as `acp_response_meta`
verbatim and namespaced as sent. Raven serving the agent side fills it from the turn's
observer stash: whatever a hook filed under `metadata["observers"]["acp_meta"]` is read
back off the turn's last substantive assistant message once the turn has landed, so the
back off the turn's last substantive assistant message once the turn has landed (off the
session, for a **Silent Turn** that wrote no assistant message), so the
loop_hooks paper's filing is the only seam a plugin needs - no plugin touches the wire, and
a turn that filed nothing answers with its stop reason alone. Raven driving the agent side
reads none of it: the table reaches the spawn record's `meta.json` and the DAG run's
Expand Down
2 changes: 1 addition & 1 deletion agents/BUILDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ override only what you need.
| `before_execute_tools` | after the model proposes tool calls, before they run | audit or veto the batch (short-circuit, rollback, note) |
| `after_iteration` | after each iteration completes | observe, budget, note |
| `terminal_answerless` | the turn ended with no answer | salvage or report |
| `after_send` | after the outbound text is sent | file a report, update state |
| `after_send` | after the outbound text is sent; `outbound_content` is None when the model ended the turn without a reply | file a report, update state |

Every phase returns a `HookDecision`. Its vocabulary, by field:

Expand Down
8 changes: 5 additions & 3 deletions docs-site/docs/proactivity-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,11 @@ while user work is busy. Wake drives Heartbeat, not Sentinel's tick loop.

A successful Cron turn that explicitly replies uses the Spine's delivery
path and does not enqueue a completion event or wake Heartbeat to repeat the
reminder. Silent completions and failures still wake Heartbeat, as do missed
reminders and automatic disabling. A successful retry clears its pending
failure event; an explicit reply also clears any earlier silent completion.
reminder. Neither does a turn whose model said nothing: the silence is its
answer, and waking Heartbeat would have a second model speak in its place.
Failures still wake Heartbeat, as do missed reminders and automatic
disabling. A successful retry clears its pending failure event; a reply, or
a turn that said nothing, also clears any earlier completion event.
Unrelated events stay queued.

The Spine's Scheduler routes turns into per-conversation Lanes and origin
Expand Down
5 changes: 3 additions & 2 deletions docs-site/docs/proactivity-design.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,9 @@ HeartbeatService(`schedulers/heartbeat/service.py`)通过结构化模型决
限制频率,并在用户工作繁忙时延后。Wake 驱动 Heartbeat,不驱动 Sentinel 的节拍循环。

已明确回复的成功 Cron 轮次通过 Spine 投递,不再生成完成事件或唤醒 Heartbeat 重复提醒。
成功但未回复的轮次、失败、错过的提醒以及自动禁用仍会唤醒 Heartbeat。
任务重试成功后会清除自身待处理的失败事件;明确回复后也会清除此前排队的静默完成事件。
模型什么都没说的轮次同样如此:沉默就是它的回答,唤醒 Heartbeat 等于让另一个模型替它开口。
失败、错过的提醒以及自动禁用仍会唤醒 Heartbeat。
任务重试成功后会清除自身待处理的失败事件;明确回复或什么都没说的轮次也会清除此前排队的完成事件。
其他事件仍保留在队列中。

Spine 的 Scheduler 将轮次路由到按会话划分的 Lane 与按来源划分的并发池;
Expand Down
3 changes: 2 additions & 1 deletion docs/Proactivity-Implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,8 @@ the dispatcher.
pops the pending messages for that session and appends them to the agent's
outgoing reply. The agent loop applies this in its `after_send` chain and skips
it for system-origin turns so a proactive reply does not get a nudge layered on
top of itself.
top of itself. A turn the model ended without a reply leaves the queue alone, so
the nudge rides the next reply rather than going out on its own.

Constraints: a TTL drops stale entries, a per-session cap evicts the oldest
beyond it (FIFO), and the pending queue is persisted through the shared
Expand Down
4 changes: 2 additions & 2 deletions docs/specs/2026-08-26-dag-node-verdict-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ node succeeded, and it is what the verdict replaces.
**How the call is made.** Following the established side-call shape in this repo,
`session/title.py` plus `rpc/session_naming.py`:

- `provider.chat_with_retry(...)` rather than `chat` -- it carries the empty-response
retry the agent loop depends on (`title.py:193`);
- `provider.chat_with_retry(...)` rather than `chat` -- it retries a provider's
transient failures, an empty-content error among them (`title.py:193`);
- the verdict comes back **through a tool call**, never as free text. A
`report_verdict` tool takes `outcome` as an enum (`accomplished` /
`not_accomplished`) and, when not accomplished, `category`, `what_is_missing` and
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/2026-10-15-turn-continuation-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ model request: the `llm.input` artifact of the turn's trace. It is a turn of its
(its own `turn_id` and lifecycle events) that the transcript draws as the rest of the
failed one.
_Avoid_: "retry" unqualified (`CONTEXT.md` already reserves it for the Model-Error
Ladder and Empty-Response Recovery); "continuation" unqualified (the DAG runner's node
Ladder); "continuation" unqualified (the DAG runner's node
continuations, `max_continuations`).

**Continuation seed.** The message list read from that artifact, resolved to full
Expand Down
4 changes: 2 additions & 2 deletions evolver/judge/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ async def call(
temperature: float = 0.0,
) -> str:
# Route through chat_with_retry (base.LLMProvider) so the judge
# inherits the empty-response retry + sync OpenAI fallback that
# the agent loop already relies on. Calling provider.chat()
# inherits the transient-failure retry + sync OpenAI fallback that
# the agent loop's own calls get. Calling provider.chat()
# directly surfaced provider empty-content errors at the judge
# layer (~3% of calls in an early dry run).
# The provider's chat[_with_retry] accepts model=None to fall
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ flowchart TD

### 2.4 文本分支

正常文本候选会在保存为本轮最终助手消息之前经过 `after_iteration`。此处可以接受、要求重采样或提供替代回答。该 Hook 位于错误和空响应恢复处理之后;那些分支如果重试或直接以错误结束,就不会经过这次 after_iteration。
正常文本候选会在保存为本轮最终助手消息之前经过 `after_iteration`。此处可以接受、要求重采样或提供替代回答。该 Hook 位于模型错误与空回复处理之后:模型调用出错时的等待重试或失败,以及既没有正文也没有工具调用的回复(什么都没说时本轮静默结束,停在思考中时本轮失败),都不会经过这次 after_iteration。

是否向外逐步发送文本受流式设置和草稿门控制。声明可能回退迭代的 Hook 会影响文本草稿何时释放;“模型产生了 token”不能直接等同于“用户已看到最终回答”。原生的具体流式路径以 Loop 和 runner 的事件为准。

Expand All @@ -84,7 +84,8 @@ flowchart TD
| Provider 内部可重试失败 | Provider 自己的重试或 fallback 路径 | 一次 `Action.decide` 不必对应一次网络请求 |
| 上下文溢出、图片格式或大小被拒绝 | Loop 向 Memory 请求对应的 shrink;有实际改变时撤回本次迭代计数,再进入受预算约束的重试 | 不消耗这次普通迭代名额;与 Participant 重采样使用不同的恢复计数和决策入口 |
| Hook 要求重采样 | Loop 检查上限,回退本次追加消息、应用允许的覆盖和注入后重试 | 请求可能被拒绝;已发生的工具副作用保留 |
| 可恢复的空响应或模型错误 | 原生恢复分类与预算决定是否再尝试 | 未经对应分支,不应把所有“无文本”都视为完成 |
| 模型错误 | Model-Error Ladder 决定是否等待后再试 | 失败与“无文本”是两回事 |
| 既没有正文也没有工具调用的回复 | 什么都没说时不再追问,本轮静默结束;停在思考中(有推理,或撞上输出上限)时按 high、low 两档逐档降低推理强度再问(本轮指定了档位时只往下降,未指定时从 high 开始),降无可降时本轮失败 | 调用过工具或输出过内容都不算空回复 |
| 迭代数、时限或重复工具停止条件触发 | Loop 可能进行一次禁用工具的总结,并使用回退回答 | 已有回复仍可能表示执行被中断,不能仅看文本判断完成 |
| 错误或没有可见最终回答 | 在没有已安排 rerun 时进入 `terminal_answerless` | `salvage` 不保证在每次预算耗尽时触发 |
| 宿主配置了 dead-end rerun | 当前 turn 内可能再运行一次 Loop attempt | attempt、iteration 和任务级 RSI 是不同层次 |
Expand Down
2 changes: 1 addition & 1 deletion plugins-dist/ppt-engine/raven_ppt/plugin/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ async def review(self, step: StepView) -> Answer:
return Accept()
text = str(getattr(response, "content", None) or "").strip()
if not text:
# Nothing said at all is the empty-recovery's case, not this one.
# Nothing said at all is an empty reply, which the loop settles first.
return Accept()
if bound is None or self._deck_mtimes_before is None:
return Accept()
Expand Down
6 changes: 3 additions & 3 deletions plugins-dist/ppt-engine/raven_ppt/plugin/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@
#: session of hundreds of long messages, and the note says when it was hit.
WORDS_CAP_CHARS = 24_000

# The loop writes user-role messages of its own -- the synthetic message that
# carries a tool's pictures, and empty-response recovery scaffolding -- and marks
# each with a private key, which is how the trunk keeps them out of a filed turn.
# Some user-role messages never came from the user -- the synthetic message that
# carries a tool's pictures, and the scaffolding a gate injects for a re-sample --
# and each carries a private key, which is how the trunk keeps them out of a filed turn.
# Read the mark rather than the prose: a live run put 29 image-withdrawal notices
# and nothing else into a journal of 34 items, and matching their wording would
# only hold until the wording changed. Spelled here so the plugin does not import
Expand Down
14 changes: 11 additions & 3 deletions raven/acp/methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -904,12 +904,14 @@ def _turn_meta(sessions: Any, session_key: str, filed_before: int) -> dict[str,
before the turn's ending reaches the stream, so by the time the
prompt's future settles the record is there. Only the messages this
turn appended are read -- an earlier turn's stash must not answer this
prompt -- and only a non-empty table counts.
prompt -- and only a non-empty table counts. A turn that appended no
assistant message files its stash on the session instead, at where the
turn began, and is read there under the same scope.
"""
if sessions is None:
return None
messages = sessions.get_or_create(session_key).messages
for message in reversed(messages[filed_before:]):
session = sessions.get_or_create(session_key)
for message in reversed(session.messages[filed_before:]):
if message.get("role") != "assistant":
continue
observers = message.get("observers")
Expand All @@ -918,6 +920,12 @@ def _turn_meta(sessions: Any, session_key: str, filed_before: int) -> dict[str,
meta = observers.get(ACP_META_OBSERVER)
if isinstance(meta, dict) and meta:
return dict(meta)
rowless = getattr(session, "metadata", {}).get("rowless_turn_observers")
if isinstance(rowless, dict) and isinstance(rowless.get("at"), int) and rowless["at"] >= filed_before:
observers = rowless.get("observers")
meta = observers.get(ACP_META_OBSERVER) if isinstance(observers, dict) else None
if isinstance(meta, dict) and meta:
return dict(meta)
return None

@staticmethod
Expand Down
Loading
Loading