Skip to content
Open
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
54 changes: 35 additions & 19 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -811,11 +811,14 @@ _Avoid_: "shadow git" as the term — Checkpoint is the per-turn snapshot it pro
**Silence Marker** (`agent/context/silence.py`):
What a model answers with to say a turn has nothing to report: `[SILENT]`, `SILENT`, `NO_REPLY`
or `NO REPLY` (`SILENCE_MARKERS`); `SILENCE_INSTRUCTION` is the two sentences that teach the
bracketed form. The turn's `Origin` picks the rule, in one place (`is_silence`). A `USER` turn, or
one with no origin, is never taught a marker, so it honours only `[SILENT]` itself with nothing but
whitespace around it: a one-word answer such as `Silent.` or `No reply.` is the person's answer, and
is delivered. A turn from a proactive origin -- `SENTINEL`, `CRON`, `HEARTBEAT` or `SUBAGENT` --
reads the whole set by two rules: the strict one (`is_exact_silence`), a reply that is exactly a
bracketed form. The turn's `Origin`, and whether its prompt taught the marker, pick the rule, in one
place (`is_silence`). A turn whose prompt does not carry `SILENCE_INSTRUCTION` (`teaches_silence`)
honours only `[SILENT]` itself with nothing but whitespace around it: a `USER` turn, one with no
origin, and a proactive one that was never taught -- a heartbeat task, a sub-agent's relayed result,
a sentinel notice. There a one-word answer such as `Silent.` or `No reply.`, or a reply ending on
one, is the model's answer, and is delivered. A turn from a proactive origin (`PROACTIVE_ORIGINS`)
whose prompt carries it -- the cron reminder and a sub-agent's unprompted report --
reads the whole set by two rules (`reads_loose_silence`): the strict one (`is_exact_silence`), a reply that is exactly a
marker, with case, edge whitespace and stray edge punctuation forgiven up to `SILENCE_MAX_CHARS`;
and the loose one (`is_loose_silence`): nobody is waiting on such a turn and models wrap the marker
in a note on why they stayed quiet, so a marker alone on the first or last line, or a bracketed
Expand All @@ -837,13 +840,16 @@ bracket goes out at once), reads past an inline think block the way the loop doe
as it arrives, and what follows it is held; a closing tag with no block open is debris, as the loop
reads it, and does not end the hold), keeps holding across a stream cut while the reply could
still be the marker, and drops what it held when the loop reads the reply as
a marker (a turn from a proactive origin, whose marker may close a report, holds the
a marker (a turn that reads the loose rule, whose marker may close a report, holds the
whole call instead). An exhausted turn's wrap-up is gated and read the same way. The marker row
stays in the history holding the model's whole reply -- the marker, or, from a proactive origin,
stays in the history holding the model's whole reply -- the marker, or, under the loose rule,
the note around it -- filed with `silent: true` so a reload does not read it as an answer, and it
carries the turn's Response Meta. The turn reports itself as
`TurnOutcome.silent` and, for a surface drawing it live, as a closing `NoticeKind.SILENT_TURN`
notice. The kernel holds no marker rules -- they live in `agent/context/silence.py` -- and the
notice. Its detail is what that reply had streamed before it was read as the marker -- at most a
think block the model inlined ahead of it -- counted over the step a reader draws it in (its call and
a cut answer's continuations, or the wrap-up alone; `LoopOutcome.shown`), so a client takes back
exactly that suffix of the step's text and keeps the narration before it. The kernel holds no marker rules -- they live in `agent/context/silence.py` -- and the
spine's `TurnEnded` carries no flag of its own; what the kernel names of silence is the outcome
(`TurnOutcome.silent`), the notice (`NoticeKind.SILENT_TURN`) and which origins are proactive
(`PROACTIVE_ORIGINS`).
Expand All @@ -852,17 +858,22 @@ The `after_send` hook chain still runs, with
queued for the next reply, and the personalizer learns nothing; text a hook composes there is
delivered as the reply, the closing row is filed as that text rather than the marker, and the turn
is then not silent. A turn the message tool has already answered (`MessageTool.sent_in_turn`: a send
to the turn's own chat that went out) is not a Silent Turn whatever it ends on -- its answer reached
the reader, so no
`SILENT_TURN` notice goes out; a marker it ends on is still filed `silent: true`, so a reload
that went out, which the tool hands to the turn's own reader whatever chat the call names), or one in
which a tool delivered files the reader now holds, is not a Silent Turn whatever it ends on -- its
answer reached the reader, so no `SILENT_TURN` notice goes out and the turn reports an explicit
reply rather than `TurnOutcome.silent`; a marker it ends on is still filed `silent: true`, so a reload
draws the muted line after the delivered reply where the live view drew none. The turn's last
assistant row with text or tool calls -- the marker's row when it ends on one, else, after an empty
closing reply, which is not stored, its last tool call, not necessarily the send; never a notice the
runtime files after the turn -- carries that live verdict as `replied_via_tool`, and the two
surfaces that read a stored turn -- the ACP replay, which draws it, and the session list, which
orders sessions by it -- read the flag from
one place (`session/turn_shape.py`) rather than guess it from the calls they can see, where a send
that failed or went to another chat would look like an answer.
that failed would look like an answer. Which turns they take back whole is one rule in the same
module (`TakeBack`), read by both and by the live turn: a turn the runtime opened that ends on a
marker no tool answered goes, the marker's row closing it. Such a turn files no unanswered-question
notice after its marker, so none is left behind as an orphan; a person's Silent Turn, which they
keep, still files it.
Distinct from a `no_output` failure. A Silent Turn is the model saying nothing on purpose, so it
succeeds; `no_output` is what a turn is failed with when its last reply was empty -- no text, no
tool call, no reasoning -- once every retry was spent, or, with recovery switched off, when its
Expand Down Expand Up @@ -893,13 +904,18 @@ continued rather than taken for silence, and a stream that had already received
keeps it, so the refusal is handled as one rather than continued. The model is asked to go on, and
the parts are joined, up to `MAX_CONTINUATIONS` times,
after which the partial is delivered with `PARTIAL_STREAM_NOTE` as an `interrupted` turn. A
continuation that comes back empty leaves what streamed as the answer, and so does one that is a
marker on its own, unless the cut left a think block open, when it is read only joined to what came
before it; one that comes back as an
error, a stream cut off before any new text included, ends the turn at once on the partial with
the note, as `interrupted`, with no wait and no fallback model -- and so does a turn that runs out
of iterations or time while a continuation is still owed, with no wrap-up. On each of those exits
the reply the turn ends on is the joined parts with their closed think blocks taken out of the whole
continuation that comes back empty leaves what streamed as the answer, and on a person's turn, which
showed what streamed, so does one that is a marker on its own; a turn that reads the loose rule showed
nothing of it, so there such a marker is read with what came before it, on its own line, as the
reply uncut would be. Either way a continuation inside a think block the cut left open is read only
joined to what came before it. One that comes back as an error, a stream cut off before any new text
included, ends the turn at once on the partial with the note, as `interrupted`, with no wait and no
fallback model, and so does a turn that runs out of iterations or time while a continuation is still
owed, with no wrap-up. One that raises ends on the partial the same way, but not at once: only after
`stream_llm_call` has spent its reconnects and, on a failure it still reads as transient, waited out
`llm_error_retry_delays` (15, 30 and 60 seconds by default), with no retry notice shown.
On each of those exits the reply the turn ends on is the joined parts with their closed think blocks
taken out of the whole
-- a cut can split one -- and a block still unclosed left in as text, read for a Silence Marker by
the turn's rule as a reply with no cut is, and
one that is a marker ends the turn as a **Silent Turn**: nothing of it shown, its row filed
Expand Down
24 changes: 14 additions & 10 deletions docs-site/docs/proactivity-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,10 @@ check-and-reserve transaction:
execution path, not a second pass through ProactiveSpawn for every action.
- Cron is explicitly user-scheduled and bypasses `check()`. With a Sentinel
runner wired, successful fires update its shared counters and topic ledger,
except a run the model ends with `[SILENT]`: it nudged no one, so it records
nothing. This helps suppress overlapping Sentinel reminders; it is not a
guarantee of semantic deduplication across all messages.
except a run the model ends with `[SILENT]` before any tool reached the user
(a message sent, files delivered): it nudged no one, so it records nothing.
This helps suppress overlapping Sentinel reminders; it is not a guarantee of
semantic deduplication across all messages.

## State and feedback

Expand Down Expand Up @@ -284,9 +285,10 @@ broadcast. `raven/core/cron_stack.py` submits the work as a `CRON` turn in
Successful recurring fires increase `silent_fire_count`; matching user
activity resets it. Reaching `silent_fire_limit` (default `12`) disables the
job. This counts fires without user activity, not failures. A run the model
ends with `[SILENT]` is not counted either: it sent the user nothing to
respond to, so a check that mostly stays quiet is not disabled for it. Cron's
feedback record is marked neutral so it does not lower Sentinel's learned
ends with `[SILENT]` before any tool reached the user (a message sent, files
delivered) is not counted either: it sent the user nothing to respond to, so a
check that mostly stays quiet is not disabled for it. Cron's feedback record is
marked neutral so it does not lower Sentinel's learned
acceptance rate. Fixed-delay `every` schedules compute their next run from
completion, not from the previous due time.

Expand All @@ -297,10 +299,12 @@ 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. The reminder also teaches the `[SILENT]` marker. Cron is a
proactive origin, so a marker alone on the first or last line, or `[SILENT]`
opening the reply, counts too, and a note on why the model stayed quiet does
not defeat it. A run that ends this way is recorded as a success and delivers
reminder. The reminder also teaches the `[SILENT]` marker, and a proactive
turn whose prompt teaches it reads the marker loosely: a marker alone on the
first or last line, or `[SILENT]` opening the reply, counts too, and a note on
why the model stayed quiet does not defeat it. A proactive turn that was never
taught the marker (a heartbeat task, a relayed sub-agent result, a sentinel
notice) honours only an exact `[SILENT]`. A run that ends this way is recorded as a success and delivers
nothing: it enqueues no event and wakes no Heartbeat. A run in which the model
produced no output after every retry is recorded as failed, delivers nothing,
and does not wake Heartbeat either. Every other failure still wakes
Expand Down
11 changes: 7 additions & 4 deletions docs-site/docs/proactivity-design.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,8 @@ ProactiveSpawn 校验任务,以任务文字作为去重内容检查共享策
在空闲检查后直接投递,不重新检查策略,因此没有完整的发送时配额或免打扰保障。
- 任务发现菜单受策略约束。用户选择后的执行是独立路径,并非每种动作都再经 ProactiveSpawn。
- Cron 是用户显式安排的任务,绕过 `check()`。接入 Sentinel runner 时,成功触发会更新
共享计数与话题账本;模型以 `[SILENT]` 结束的运行没有提醒任何人,不做记录。
共享计数与话题账本;模型以 `[SILENT]` 结束、此前也没有工具触达用户(发出消息、投递文件)的运行
没有提醒任何人,不做记录。
这有助于抑制重叠的 Sentinel 提醒,但不能保证所有消息语义上绝不重复。

## 状态与反馈 { #state-and-feedback }
Expand Down Expand Up @@ -229,7 +230,8 @@ CronService(`schedulers/cron/service.py`)在文件锁下持久化任务、

周期任务成功触发会增加 `silent_fire_count`,匹配的用户活动会将其重置。
达到 `silent_fire_limit`(默认 `12`)后任务被禁用。这里计数的是没有用户活动的触发,
不是失败次数。模型以 `[SILENT]` 结束的运行同样不计数:它没有给用户发任何内容,
不是失败次数。模型以 `[SILENT]` 结束、此前也没有工具触达用户(发出消息、投递文件)的运行
同样不计数:它没有给用户发任何内容,
用户无从回应,所以多数时候保持沉默的检查类任务不会因此被禁用。
Cron 的反馈记录记为中性,不会拉低 Sentinel 学习到的接受率。
固定延迟的 `every` 计划从完成时刻计算下一次运行时间,而不是从上一次应触发时刻计算。
Expand All @@ -239,9 +241,10 @@ HeartbeatService(`schedulers/heartbeat/service.py`)通过结构化模型决
限制频率,并在用户工作繁忙时延后。Wake 驱动 Heartbeat,不驱动 Sentinel 的节拍循环。

已明确回复的成功 Cron 轮次通过 Spine 投递,不再生成完成事件或唤醒 Heartbeat 重复提醒。
提醒文本还会教模型使用 `[SILENT]` 标记。Cron 属于主动来源(proactive origin),
提醒文本还会教模型使用 `[SILENT]` 标记;prompt 教过标记的主动来源(proactive origin)轮次按宽松规则读:
标记单独占第一行或最后一行、或回复以 `[SILENT]` 开头,同样算沉默,
所以模型附带的一句说明不会让标记失效。以标记结束的运行记为成功、不投递:
所以模型附带的一句说明不会让标记失效。没有教过标记的主动轮次(heartbeat 任务、转述的子代理结果、
sentinel 通知)只认完整的 `[SILENT]`。以标记结束的运行记为成功、不投递:
不生成事件,也不唤醒 Heartbeat。模型在所有重试后仍什么都没给的运行记为失败、不投递,
同样不唤醒 Heartbeat。其他失败、既没有回复也没有标记的成功轮次、错过的提醒以及自动禁用
仍会唤醒 Heartbeat。任务重试成功后会清除自身待处理的失败事件;明确回复或以 `[SILENT]`
Expand Down
33 changes: 15 additions & 18 deletions raven/acp/replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
from raven.acp.tool_kinds import locations, title_for, tool_kind
from raven.acp.updates import notice_text, turn_failure_text
from raven.security import redact
from raven.session.turn_shape import replied_via_message_tool
from raven.session.turn_shape import TakeBack, TakeBackStep

# A replayed transcript is bounded by what a client can draw, not by what is
# stored: a session with two thousand messages would otherwise emit two thousand
Expand Down Expand Up @@ -92,26 +92,23 @@ def _without_silent_runtime_turns(entries: list[dict[str, Any]]) -> list[dict[st

The marker is sent as no text, so such a turn replayed as the runtime's prose
for the model, drawn as a user message, with no answer under it. The whole
turn goes, the narration and tool calls on the way included, by the rule the
session list reads a transcript with (``SessionManager._scan_file``): only a
user row carrying ``origin`` opens a turn that can be taken back, so a
person's own message stays when the model answers it with silence. A turn
the message tool answered stays too: it is not a Silent Turn whatever it ends
on, and its reply reached the reader. Its marker's row carries the live turn's
verdict, read from one place (``raven/session/turn_shape.py``), so the session
list and this replay cannot come to disagree about which turn answered.
turn goes, the narration and tool calls on the way included. Which turns go is
the rule the session list and the live turn read too (``TakeBack`` in
``raven/session/turn_shape.py``): only a user row carrying ``origin`` opens a
turn that can be taken back, so a person's own message stays when the model
answers it with silence, and a turn a tool answered stays as well, its reply
having reached the reader.
"""
kept: list[dict[str, Any]] = []
opened_at: int | None = None
opened_at = 0
take_back = TakeBack()
for entry in entries:
role = entry.get("role")
if role == "user":
opened_at = len(kept) if entry.get("origin") else None
elif role == "assistant" and entry.get("silent") is True and opened_at is not None:
start, opened_at = opened_at, None
if not replied_via_message_tool(entry):
del kept[start:]
continue
step = take_back.read(entry)
if step is TakeBackStep.OPENS:
opened_at = len(kept)
elif step is TakeBackStep.TAKES_BACK:
del kept[opened_at:]
continue
kept.append(entry)
return kept

Expand Down
Loading
Loading