send 失败不再吃掉这一轮:模型知情 + 对话内容无条件进 Delta #106

Merged
Yushu merged 2 commits from fix/95-send-failure-fallback into main 2026-07-30 08:22:36 +00:00
Member

Closes #95

ADR-0012 §7 断言「send 失败下一轮回灌上下文(m7 发送失败)供模型知情」,而 _send_pending裸调用。后果比 ADR 描述的更重:_append_delta_flush 之后才跑,所以一次平台侧失败会让这一轮对话在智能体的记忆里彻底不存在 —— 既不知道发失败,也不记得说过什么。QQ 的禁言/风控/频控让这条路径是常态而非异常。

三层保障

做法
单条发送失败 各自 try,只 continue —— 同批其余消息照常发(AC①)
对话内容 run()try/finally 无条件写 Delta(AC④,本票真正的价值
模型知情 新增待告知记录 + context 多一段,措辞照 ADR-0012 的 m7 发送失败(AC②③)

finally 而不是只包发送_run_tool_loop 里还有六个裸的平台调用recall×2 / edit / poke_send / reaction_send / like_profile),它们跑在 _flush 之前,抛出来照样让本轮对话一个字都不进缓冲。本文件 _handle_search_stickers 那段既有注释早就写过这个形状(「_run_tool_loop 没有任何 except,反应式那条路是裸 create_task,冒泡出去整条回复会静默消失」)。finally 不吞异常 —— 原异常照常上抛,只是抛之前先把话记下来。

几处刻意的设计

  • 失败的句柄不进 Sent Log(对 AC「Sent Log 侧」措辞的偏离)。它没有 message_id,塞个空串进已发日志就会让 self_recall/edit 去撤/改一条根本不存在的平台消息;不写反而让那两个工具自然回 handle not found —— 对一个从没发出去的句柄,那正是正确答案。另外这是一次性通知,混进永久账本还得再加个「已告知」标志位。
  • 读与清分两步list_send_failures(只读)+ clear_send_failures(模型真看过、context 已进过一次 complete() 才清)。合成「取完即消」的话,反应式那条可被下一条消息 cancel() 掉的裸 task 就会让通知在「已删、模型未看到」的窗口里静默消失。
  • 积压有上限MAX_PENDING_SEND_FAILURES = 20,超出丢最早的):消费方只有反应式入口,「对方把机器人拉黑了」这种 chat(发送恒失败、Drive Tick 照跑、人再也不说话)会一直攒不消。
  • 不说「上一轮」:这些失败可能来自若干次 Drive Tick 主动开口之前,写「上一轮」就是在陈述一件核实不了的事。有守卫钉住。
  • 失败的内容不进 Delta 缓冲:它没送达对方,记成「我说过」会让后续压缩提炼出一段从未发生的对话。(CONTEXT.md 明文「只有最终经 send_message 发出的自然语言回复参与常规记忆压缩」;沉默窗口打断早有「组装了但没送达 → 不进 Delta」先例。)
  • Outbox 那行要清掉:它表达「在途请求」,而这次已经确定发不出去了。
  • 异常本体不记日志、不进存储:本模块刻意不依赖 nonebot.log(框架无关,同 reflection.py/subagent.py 先例);「平台报了什么错」host 自己的 egress 实现比 core 清楚。core 只记事实 —— 那是它需要告诉模型的东西。
  • except Exception 刻意宽:也会接住 host egress 的 bug 与能力位错配(NotImplementedError)。把那些报成「发送失败」不算错(消息确实没出去),放它冒泡就是把这个洞原样打开。已核实 PoolExhausted 到不了这里(熔断只包 LLM/embedding 客户端),CancelledErrorBaseException 不被接住。
  • 打字提示的失败一并兜住:它是纯装饰性的平台调用却排在所有发送之前 —— 不接住的话,一次「提示发不出去」会让整批真正的回复一条都发不出去。

两轴 review 抓到的(第二个 commit)

标准轴那个 agent 因安全分类服务不可用没起来;规格轴抓到四条,全部成立:

  1. Delta 保障只覆盖了发送这一处 → 改成 try/finally 覆盖全路径。
  2. 一次性消费的丢失窗口比我 docstring 承认的大。我写「报了再标记已报有同样大小的窗口」—— 是错的,那种做法在 LLM 调用之后才标记,被取消掉不会丢。→ 改成读写分离。
  3. 积压无上限 → 加 MAX_PENDING_SEND_FAILURES
  4. 措辞「上一轮」核实不了 → 改措辞 + 加守卫。

顺带把打字提示那段注释里已经不再准确的理由改了(有了 finally 之后它跟「记忆」无关了,真实理由是「别让装饰性调用失败连带整批回复发不出去」)。

已知未做

  • 那六个工具循环里的平台调用仍是裸的:异常仍会冒泡终止本轮回复(只是不再丢记忆)。把它们各自翻译成工具结果涉及「模型该看到什么」,是另一个决定,AC 只管发送路径。
  • 决策快照不含「发送失败」这个事实:整批发失败时 sends=0 + skipped_message_ids=整批,与「模型选择沉默」不可区分,/why 会答成「它选择没理你」。加字段要改三处 + 两个行映射,属 issue #79 的地盘。

验证

按 AC 提醒,刻意不写「没崩溃就算过」那种断言 —— 每条用例锁的都是实际内容:Delta 缓冲里到底有什么、下一轮 prompt 里到底有哪句话。存储契约两套实现共用同一套断言(读完即清在 SQL 侧是两条语句,写错会变成重复告知或静默消失;条数上限在 SQL 侧是 subquery + delete)。

uv run pytest -n auto1501 passed(新增 30)。ruff check 全过。

变异复验(每次都先核实变异真写进了文件;两次锚点/语法不合格的变异被脚本挡下并重做,没有当成证据):失败改为向上抛 → 6 红;不记失败 → 2 红;上下文不加失败段 → 2 红;只在无异常时写 Delta → 1 红;先清后跑 → 1 红;去掉条数上限 → 1 红;措辞改回「上一轮」→ 1 红;打字提示不再兜住 → 1 红。还原后各文件 md5 与备份一致。

Closes #95 ADR-0012 §7 断言「send 失败下一轮回灌上下文(`m7 发送失败`)供模型知情」,而 `_send_pending` 是**裸调用**。后果比 ADR 描述的更重:`_append_delta` 在 `_flush` **之后**才跑,所以一次平台侧失败会让这一轮对话在智能体的记忆里**彻底不存在** —— 既不知道发失败,也不记得说过什么。QQ 的禁言/风控/频控让这条路径是常态而非异常。 ## 三层保障 | 层 | 做法 | |---|---| | 单条发送失败 | 各自 try,只 `continue` —— 同批其余消息照常发(AC①)| | 对话内容 | `run()` 用 `try/finally` 无条件写 Delta(AC④,**本票真正的价值**)| | 模型知情 | 新增待告知记录 + context 多一段,措辞照 ADR-0012 的 `m7 发送失败`(AC②③)| **`finally` 而不是只包发送**:`_run_tool_loop` 里还有**六个裸的平台调用**(`recall`×2 / `edit` / `poke_send` / `reaction_send` / `like_profile`),它们跑在 `_flush` **之前**,抛出来照样让本轮对话一个字都不进缓冲。本文件 `_handle_search_stickers` 那段既有注释早就写过这个形状(「`_run_tool_loop` 没有任何 except,反应式那条路是裸 `create_task`,冒泡出去整条回复会静默消失」)。`finally` 不吞异常 —— 原异常照常上抛,只是抛之前先把话记下来。 ## 几处刻意的设计 - **失败的句柄不进 Sent Log**(对 AC「Sent Log 侧」措辞的偏离)。它没有 `message_id`,塞个空串进已发日志就会让 `self_recall`/`edit` 去撤/改一条根本不存在的平台消息;不写反而让那两个工具自然回 `handle not found` —— 对一个从没发出去的句柄,那正是正确答案。另外这是**一次性通知**,混进永久账本还得再加个「已告知」标志位。 - **读与清分两步**:`list_send_failures`(只读)+ `clear_send_failures`(模型真看过、context 已进过一次 `complete()` 才清)。合成「取完即消」的话,反应式那条可被下一条消息 `cancel()` 掉的裸 task 就会让通知在「已删、模型未看到」的窗口里静默消失。 - **积压有上限**(`MAX_PENDING_SEND_FAILURES = 20`,超出丢最早的):消费方只有反应式入口,「对方把机器人拉黑了」这种 chat(发送恒失败、Drive Tick 照跑、人再也不说话)会一直攒不消。 - **不说「上一轮」**:这些失败可能来自若干次 Drive Tick 主动开口之前,写「上一轮」就是在陈述一件核实不了的事。有守卫钉住。 - **失败的内容不进 Delta 缓冲**:它没送达对方,记成「我说过」会让后续压缩提炼出一段从未发生的对话。(CONTEXT.md 明文「只有最终经 `send_message` **发出的**自然语言回复参与常规记忆压缩」;沉默窗口打断早有「组装了但没送达 → 不进 Delta」先例。) - **Outbox 那行要清掉**:它表达「在途请求」,而这次已经确定发不出去了。 - **异常本体不记日志、不进存储**:本模块刻意不依赖 `nonebot.log`(框架无关,同 `reflection.py`/`subagent.py` 先例);「平台报了什么错」host 自己的 egress 实现比 core 清楚。core 只记**事实** —— 那是它需要告诉模型的东西。 - **`except Exception` 刻意宽**:也会接住 host egress 的 bug 与能力位错配(`NotImplementedError`)。把那些报成「发送失败」不算错(消息确实没出去),放它冒泡就是把这个洞原样打开。已核实 `PoolExhausted` 到不了这里(熔断只包 LLM/embedding 客户端),`CancelledError` 是 `BaseException` 不被接住。 - **打字提示的失败一并兜住**:它是纯装饰性的平台调用却排在所有发送之前 —— 不接住的话,一次「提示发不出去」会让整批真正的回复一条都发不出去。 ## 两轴 review 抓到的(第二个 commit) 标准轴那个 agent 因安全分类服务不可用没起来;规格轴抓到四条,全部成立: 1. **Delta 保障只覆盖了发送这一处** → 改成 `try/finally` 覆盖全路径。 2. **一次性消费的丢失窗口比我 docstring 承认的大**。我写「报了再标记已报有同样大小的窗口」—— **是错的**,那种做法在 LLM 调用之后才标记,被取消掉不会丢。→ 改成读写分离。 3. **积压无上限** → 加 `MAX_PENDING_SEND_FAILURES`。 4. **措辞「上一轮」核实不了** → 改措辞 + 加守卫。 顺带把打字提示那段注释里已经不再准确的理由改了(有了 `finally` 之后它跟「记忆」无关了,真实理由是「别让装饰性调用失败连带整批回复发不出去」)。 ## 已知未做 - **那六个工具循环里的平台调用仍是裸的**:异常仍会冒泡终止本轮回复(只是不再丢记忆)。把它们各自翻译成工具结果涉及「模型该看到什么」,是另一个决定,AC 只管发送路径。 - **决策快照不含「发送失败」这个事实**:整批发失败时 `sends=0` + `skipped_message_ids=整批`,与「模型选择沉默」不可区分,`/why` 会答成「它选择没理你」。加字段要改三处 + 两个行映射,属 issue #79 的地盘。 ## 验证 按 AC 提醒,刻意不写「没崩溃就算过」那种断言 —— 每条用例锁的都是实际内容:Delta 缓冲里到底有什么、下一轮 prompt 里到底有哪句话。存储契约两套实现共用同一套断言(读完即清在 SQL 侧是两条语句,写错会变成重复告知或静默消失;条数上限在 SQL 侧是 subquery + delete)。 `uv run pytest -n auto` → **1501 passed**(新增 30)。`ruff check` 全过。 变异复验(每次都先核实变异真写进了文件;两次锚点/语法不合格的变异被脚本挡下并重做,没有当成证据):失败改为向上抛 → 6 红;不记失败 → 2 红;上下文不加失败段 → 2 红;只在无异常时写 Delta → 1 红;先清后跑 → 1 红;去掉条数上限 → 1 红;措辞改回「上一轮」→ 1 红;打字提示不再兜住 → 1 红。还原后各文件 md5 与备份一致。
ADR-0012 §7 断言"send 失败下一轮回灌上下文(`m7 发送失败`)供模型知情",而
`_send_pending` 是**裸调用**——`runtime_loop.py` 全文只有两处 `except` 且都是
`PoolExhausted`、都不在发送路径。

后果比 ADR 描述的更重:`_append_delta` 在 `_flush` **之后**才调用,所以发送抛异常时
①模型下一轮不知道发失败 ②**连那几条内容本身都不记得**(Delta 缓冲根本没写)。一次
平台侧失败会让这一轮对话在智能体的记忆里彻底不存在。QQ 的禁言/风控/频控让这条路径
是常态而非异常。ADR-0013 不覆盖它:那份管进程崩溃,不管"平台 API 返回错误但进程活着"。

## 三处改动

1. **每条发送各自 try,单条失败只 `continue`**:同批其余消息照常发(AC),
   `_flush` 不再向上抛,于是 `_append_delta` 一定跑得到——AC 说这条是本票真正的
   价值所在,比"模型知情"那半更重要。节奏停顿仍只在真发出过至少一条之后才有
   (第一条失败时没有"上一条"可承接节奏)。
2. **新增 `SendFailureRecord` + `arise_send_failures` 表**,`take_send_failures`
   读完即删(一次性通知)。`assemble_context` 多一段"上一轮有消息没能发出去",
   措辞照 ADR-0012 原文的 `m7 发送失败`,只在真有失败时才产生元素。
3. **打字提示的失败一并兜住**:它是纯装饰性的平台调用,却排在所有发送之前
   ——不接住它就是"一次平台故障吃掉整轮记忆"的最短路径。

## 几处刻意的设计

- **失败的句柄不进 Sent Log。** 它没有 `message_id`,塞个空串进已发日志就会让
  `self_recall`/`edit` 拿它去撤/改一条根本不存在的平台消息;不写反而让那两个工具
  自然回"handle not found"——对一个从没发出去的句柄,那正是正确答案。另外这是
  一次性通知,混进永久账本还得再加个"已告知"标志位。
- **Outbox 那行要清掉**:它表达"在途请求",而这次已经确定发不出去了。
- **失败的内容不进 Delta 缓冲**:它没送达对方,记成"我说过"会让后续压缩提炼出
  一段从未发生的对话。
- **异常本体不记日志、不进存储**:本模块刻意不依赖 `nonebot.log`(框架无关,同
  `reflection.py`/`subagent.py` 先例);而"平台报了什么错"host 自己的 egress 实现
  比 core 清楚,要记该它记。core 只记事实——那是它需要告诉模型的东西。
- **只有反应式入口消费通知**:取完即消,让事件驱动那几条(Drive Tick/环境信号/
  Callback)也消费的话,一次"它自己想说话"就把告知机会用掉了。
- **不重试**(AC Not in scope):core 不了解任意平台的幂等性语义。

## 测试

按 AC 提醒,刻意不写"没崩溃就算过"那种断言——每条用例锁的都是实际内容:Delta 缓冲里
到底有什么、下一轮 prompt 里到底有哪句话。存储契约两套实现共用同一套断言(`take`
的读完即删在 SQL 侧是 select+delete 两条语句,写错会变成重复告知或静默消失)。

变异复验(每次都先核实变异真写进文件):失败改为向上抛 → 6 红;不记失败 → 2 红;
上下文不加失败段 → 2 红;take 不清空 → 3 红;打字提示不再兜住 → 1 红。还原后两个
文件 md5 均与备份一致。全量 1494 passed(新增 23)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
规格轴(标准轴那个 agent 因安全分类服务不可用没起来)抓到四条,都成立,已逐条核实:

## 1. Delta 保障只覆盖了发送这一处

`_flush` 里每条发送各自 try 只堵住发送。`_run_tool_loop` 里还有**六个裸的平台调用**
(`recall` ×2 / `edit` / `poke_send` / `reaction_send` / `like_profile`),它们跑在
`_flush` **之前**,抛出来照样让本轮对话一个字都不进缓冲——本文件
`_handle_search_stickers` 那段既有注释早就写过这个形状("`_run_tool_loop` 没有任何
except,反应式那条路是裸 `create_task`,冒泡出去整条回复会静默消失")。而 AC 说
"对话内容仍进 Delta 缓冲"才是本票真正的价值。

改成 `try/finally`:`_append_delta` 无条件跑。**不给那六处各包一个 try**——把它们的
异常翻译成工具结果是另一个决定(涉及"模型该看到什么"),而"用户说了什么不能丢"跟
失败发生在哪儿无关。`finally` 不吞异常,原异常照常上抛。

## 2. 一次性消费的丢失窗口比我文档承认的大

`take_send_failures` 在 `run()` 最开头,而反应式那条路是可被下一条消息
`previous_flush.cancel()` 掉的裸 task——先清后跑,下一条消息就能在"通知已删、模型
未看到"时掐掉整轮。我原 docstring 写"报了再标记已报有同样大小的窗口",**是错的**
(那种做法在 LLM 调用之后才标记,取消掉不会丢)。

改成读写分离:`list_send_failures`(只读)+ `clear_send_failures`(模型真看过之后
才清,即 context 已经进过一次 `complete()`)。

## 3. 积压无上限

消费方只有反应式入口,于是"对方把机器人拉黑了"这种 chat(发送恒失败、Drive Tick
照跑、人再也不说话)会一直攒不消,之后一次反应式轮把几十条塞进 prompt。加
`MAX_PENDING_SEND_FAILURES = 20`,超出丢最早的;SQL 侧在同一次事务里做完(分两次
提交的话中间崩溃会留下一个永远超上限的 chat)。

## 4. 措辞在陈述一件核实不了的事

原文案说"上一轮有消息没能发出去",但消费方只有反应式入口,这些失败可能来自若干次
Drive Tick 主动开口之前。改成"你之前有消息没能发出去",并补一条守卫钉住不许再说
"上一轮"。

## 其它

- `except Exception` 的宽度如实写进注释:它也会接住 host egress 自己的 bug 与能力位
  错配(`NotImplementedError`),把那些报成"发送失败"不算错(消息确实没出去),但
  排查得看 host 侧日志。已核实 `PoolExhausted` 到不了这里(熔断只包 LLM/embedding
  客户端),`CancelledError` 是 `BaseException` 不被接住。
- 打字提示那段注释原来说"不接住就是吃掉整轮记忆的最短路径"——有了 `finally` 之后这句
  不再准确,它的真实理由是"别让一次装饰性调用失败连带整批回复都发不出去"。已改,
  对应测试的 docstring 一并改。

## 已知未做(PR 正文有完整说明)

- 那六个工具循环里的平台调用仍是裸的(异常仍会冒泡终止本轮回复,只是不再丢记忆)。
- 决策快照不含"发送失败"这个事实,所以整批发失败时 `/why` 会答成"它选择没理你"
  ——加字段要改三处 + 两个行映射,属 issue #79 的地盘。

变异复验(每次都先核实变异真写进文件;两次锚点/语法不合格的变异被脚本挡下并重做):
只在无异常时写 Delta → 1 红;先清后跑 → 1 红;去掉条数上限 → 1 红;措辞改回"上一轮"
→ 1 红。还原后两文件 md5 与备份一致。全量 1501 passed(新增 30)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Yushu merged commit f9bcff03a3 into main 2026-07-30 08:22:36 +00:00
Yushu deleted branch fix/95-send-failure-fallback 2026-07-30 08:22:37 +00:00
Sign in to join this conversation.
No description provided.