feat: 即刻追问 + 实时状态感知(issue #42) #51

Merged
Yushu merged 1 commit from feature/42-immediate-followup-realtime-presence into main 2026-07-24 03:12:29 +00:00
Member

这个 PR 做了什么

实现 issue #42「即刻追问 + 实时状态感知」(ADR-0025/0026)。这一片是
Phase 4 最后一片,范围明显小于 #41——ADR 已经把几乎所有实现决策钉死,
只给主动出口(issue #38 建好的 Recall/Drive Tick)多接一根触发线,不新开
任何独立主动出口。

即刻追问

  • send_message 新增可选参数 expects_reply: bool——模型显式声明这条
    消息是否在等待回应,不用"问号结尾"之类的文本启发式推断(同
    quote_message_id 一样的显式声明模式)。
  • 私聊:复用 Recall 单一出口。短窗口(arise_immediate_followup_window_seconds
    无回应,写一次"Recall 时机覆盖"(immediate_followup.py 新增
    RecallTimingOverride),Drive Tick 扫描时额外检查这个覆盖是否到期
    is_due_via_timing_override——判定"是否已回应"复用既有
    ReconnectCooldownState,不新开追踪),决策仍完整走 Recall 既有门控。
    覆盖一次性:评估完毕即清掉。
  • 群聊:没有 Recall 冷场计时可复用,改用一次性 asyncio 延迟任务
    (同 debounce 先例)。到期检查"这期间有没有新消息"(活跃度信号只在
    真正经过反应式入口——即 to_me() 命中——的消息上更新,群里其他人之间
    的閒聊不算"回应机器人",同 ADR-0014 tier-1"群里多热闹"与"关于我自己
    的判断"两码事的既有区分),无新消息则走一次事件驱动评估。
  • 新增 RuntimeLoop.run_immediate_followup():复用与 tier-2 环境信号
    (issue #39)完全相同的画像/召回/关系网组装管线——把共用部分抽成
    _run_event_driven_turn 私有方法,run_environment_signal/
    run_immediate_followup 都只是喂不同的指令文案,不是两份平行实现。
  • 不设熟悉度专属硬门槛:两条路径都调用同一个 _evaluate_unified_gate
    (反应式消息也用这个),不是另开一套判断——两级门控级二本来就读得到
    关系多轴快照,加上渐进解锁硬门槛,足够覆盖"不该跟陌生人瞎追问"这件事。

实时状态感知(私聊专属)

  • 在线状态变化(host push,机制类似 poke)复用同一个 Recall 时机
    覆盖接入点——继"消息期待回应"之后第二个信号源。
  • 输入状态变化落一条纯被动参考事实typing_status.py):挂进反应式
    run() 的冻结快照,供模型自己判断要不要等一等再发;不接入已读延迟窗,
    也不触发任何门控评估(有专门的 e2e 测试证明"对方开始输入"这个事件
    本身不会导致任何一次评估被触发)。
  • capabilities.py 新增独立能力位 read_online_status/
    read_typing_status(同 poke_send/reaction_send 一路的颗粒度先例),
    IngressPort 新增 to_online_status_change/to_typing_status_change

新增/改动文件

  • 新增:immediate_followup.py(时机覆盖判定 + 事件驱动指令文案)、
    typing_status.py(输入状态纯参考事实)
  • tools.pysend_message schema 新增 expects_reply
  • runtime_loop.py_PendingSend/SendResult 透传 expects_reply
    提取 _run_event_driven_turn 共用私有方法;run() 冻结快照新增输入
    状态事实
  • capabilities.py/ports.py:新能力位 + ingress 方法
  • storage.py/sent_log.pyRecallTimingOverride/TypingStatusState
    两张新表/结构的内存版 + 持久化版实现
  • __init__.py_debounced_flush 发送后按私聊/群聊分流挂追问检查;
    新增 _schedule_group_followup_check/_run_group_followup_check
    _drive_tick 扫描接入时机覆盖判定;新增 _handle_presence_events
    postprocessor
  • config.py:新增短窗口/输入状态新鲜度窗口配置

测试

新增 6 个测试文件:纯函数(时机覆盖判定的边界/回应即失效场景)、存储
契约(内存版+持久化版共用)、run_immediate_followup 的 RuntimeLoop
级测试、expects_reply 透传测试、输入状态注入测试、实时状态感知的
处理逻辑测试(绕开 OneBot v11 标准协议没有对应 notice 事件这一层,同
test_environmental_awareness_e2e.py 先例)+ 端到端可演示场景(私聊/
群聊 expects_reply 无回应触发追问、群聊短窗口内被回应则不触发)。

全部 807 个测试通过,ty check/ruff check 干净。

## 这个 PR 做了什么 实现 issue #42「即刻追问 + 实时状态感知」(ADR-0025/0026)。这一片是 Phase 4 最后一片,范围明显小于 #41——ADR 已经把几乎所有实现决策钉死, 只给主动出口(issue #38 建好的 Recall/Drive Tick)多接一根触发线,不新开 任何独立主动出口。 ### 即刻追问 - `send_message` 新增可选参数 `expects_reply: bool`——模型显式声明这条 消息是否在等待回应,不用"问号结尾"之类的文本启发式推断(同 `quote_message_id` 一样的显式声明模式)。 - **私聊**:复用 Recall 单一出口。短窗口(`arise_immediate_followup_window_seconds`) 无回应,写一次"Recall 时机覆盖"(`immediate_followup.py` 新增 `RecallTimingOverride`),Drive Tick 扫描时额外检查这个覆盖是否到期 (`is_due_via_timing_override`——判定"是否已回应"复用既有 `ReconnectCooldownState`,不新开追踪),决策仍完整走 Recall 既有门控。 覆盖一次性:评估完毕即清掉。 - **群聊**:没有 Recall 冷场计时可复用,改用一次性 asyncio 延迟任务 (同 debounce 先例)。到期检查"这期间有没有新消息"(活跃度信号只在 真正经过反应式入口——即 `to_me()` 命中——的消息上更新,群里其他人之间 的閒聊不算"回应机器人",同 ADR-0014 tier-1"群里多热闹"与"关于我自己 的判断"两码事的既有区分),无新消息则走一次事件驱动评估。 - 新增 `RuntimeLoop.run_immediate_followup()`:复用与 tier-2 环境信号 (issue #39)完全相同的画像/召回/关系网组装管线——把共用部分抽成 `_run_event_driven_turn` 私有方法,`run_environment_signal`/ `run_immediate_followup` 都只是喂不同的指令文案,不是两份平行实现。 - **不设熟悉度专属硬门槛**:两条路径都调用同一个 `_evaluate_unified_gate` (反应式消息也用这个),不是另开一套判断——两级门控级二本来就读得到 关系多轴快照,加上渐进解锁硬门槛,足够覆盖"不该跟陌生人瞎追问"这件事。 ### 实时状态感知(私聊专属) - 在线状态变化(host push,机制类似 poke)复用**同一个** Recall 时机 覆盖接入点——继"消息期待回应"之后第二个信号源。 - 输入状态变化落一条**纯被动参考事实**(`typing_status.py`):挂进反应式 `run()` 的冻结快照,供模型自己判断要不要等一等再发;不接入已读延迟窗, 也不触发任何门控评估(有专门的 e2e 测试证明"对方开始输入"这个事件 本身不会导致任何一次评估被触发)。 - `capabilities.py` 新增独立能力位 `read_online_status`/ `read_typing_status`(同 `poke_send`/`reaction_send` 一路的颗粒度先例), `IngressPort` 新增 `to_online_status_change`/`to_typing_status_change`。 ### 新增/改动文件 - 新增:`immediate_followup.py`(时机覆盖判定 + 事件驱动指令文案)、 `typing_status.py`(输入状态纯参考事实) - `tools.py`:`send_message` schema 新增 `expects_reply` - `runtime_loop.py`:`_PendingSend`/`SendResult` 透传 `expects_reply`; 提取 `_run_event_driven_turn` 共用私有方法;`run()` 冻结快照新增输入 状态事实 - `capabilities.py`/`ports.py`:新能力位 + ingress 方法 - `storage.py`/`sent_log.py`:`RecallTimingOverride`/`TypingStatusState` 两张新表/结构的内存版 + 持久化版实现 - `__init__.py`:`_debounced_flush` 发送后按私聊/群聊分流挂追问检查; 新增 `_schedule_group_followup_check`/`_run_group_followup_check`; `_drive_tick` 扫描接入时机覆盖判定;新增 `_handle_presence_events` postprocessor - `config.py`:新增短窗口/输入状态新鲜度窗口配置 ### 测试 新增 6 个测试文件:纯函数(时机覆盖判定的边界/回应即失效场景)、存储 契约(内存版+持久化版共用)、`run_immediate_followup` 的 RuntimeLoop 级测试、`expects_reply` 透传测试、输入状态注入测试、实时状态感知的 处理逻辑测试(绕开 OneBot v11 标准协议没有对应 notice 事件这一层,同 `test_environmental_awareness_e2e.py` 先例)+ 端到端可演示场景(私聊/ 群聊 `expects_reply` 无回应触发追问、群聊短窗口内被回应则不触发)。 全部 807 个测试通过,`ty check`/`ruff check` 干净。
给 issue #38 建好的 Recall/Drive Tick 两条出口多接一根线,不新开任何
主动出口:send_message 新增可选参数 expects_reply,模型显式声明这条
消息是否在等回应(不用问号结尾之类的文本启发式猜)。私聊复用 Recall
单一出口——短窗口无回应触发一次"时机覆盖",让 Drive Tick 扫描提前判定
到期,决策仍走 Recall 既有门控;群聊没有冷场计时可复用,改用一次性
延迟检查直接触发一次事件驱动评估(新增 RuntimeLoop.run_immediate_followup,
复用与 tier-2 环境信号完全相同的画像/召回/关系网组装管线,只是指令
文案不同)。不设熟悉度专属硬门槛——两条路径都复用同一套两级门控
(_evaluate_unified_gate),不是另开判断。

实时状态感知复用同一个"Recall 时机覆盖"接入点:在线状态变化(host push,
机制类似 poke)触发时机覆盖;输入状态变化只落一条纯被动参考事实(挂进
反应式冻结快照,不接入已读延迟窗,不触发任何门控评估)。两个能力位
(read_online_status/read_typing_status)独立开位,优雅降级。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Yushu merged commit f467775a39 into main 2026-07-24 03:12:29 +00:00
Yushu deleted branch feature/42-immediate-followup-realtime-presence 2026-07-24 03:12:30 +00:00
Sign in to join this conversation.
No description provided.