✨ feat: 日记 Agent 工具 —— 角色当场记一笔,零 LLM 调用(ADR-0005 模式 B) - #36
Conversation
ADR-0005 承诺两种写入模式,此前只有模式 A(后台抽取 `memorize()`,#24)。 补上模式 B:角色在对话**当中**决定"这个我记下来",作为一次 tool call 落盘。 diary_tool() # append_diary(content) 的 function-call schema handle_diary_tool(diary, args) # 落盘,返回 DiaryItem handler 里**没有任何 LLM 调用** —— Agent 本身就是那个模型,正文是它写回复时 自己写的。这是模式 B 在"每轮 ≤ 1 次 LLM 调用"里仍然成立的原因。 三个刻意的设计取舍: - **schema 只暴露 `content`。** 模型没有时钟,它给的日期只能是猜的,而猜错的 那条会落在错误的一天且毫无破绽。`date`/`time`/`owner` 由宿主以关键字传入 —— 与模式 A"模型写什么、宿主 stamp 何时"同一条分工。 - **它抛异常,而 `memorize()` fail-open。** 两边的"空"意思相反:模式 A 返回 `[]` 是"这轮没什么值得记的"(健康);一次坏掉的 tool call 是角色刚说要记下 来、却什么都没落地 —— 这里"悄悄成功"才是最坏结果。异常消息按"可直接回传给 Agent 当 tool 结果"来写,Agent 能在同一轮自我纠正。多出来的参数一并拒绝, 不静默丢弃。 - **description 与 `DIARY_PROMPT` 共用同一套文风规则**(ADR-0005 §4)。测试 逐条比对,防止两种模式漂移成两种口吻。 测试:213 passed, 1 skipped(新增 17 条;基线 196)。文档 EN + zh 同步, 《写日记》两页新增模式 B 的完整 Agent 循环,api reference 两页补条目。 Co-Authored-By: Claude <noreply@anthropic.com>
xnne-bot
left a comment
There was a problem hiding this comment.
代码实现和设计非常干净,三个取舍点的考虑很到位:
- Schema 隐藏时间与溯源字段:由宿主统一 stamp Thu Jul 30 03:10:32 PM UTC 2026//,避免模型伪造时间导致静默错误。
- **校验失败显式抛 **:区分了 的 fail-open 逻辑,工具调用失败直接抛出友好 Error 消息便于 Agent 循环自愈。
- 文风规则一致性断言:在测试里锁定了 与 的规则逐字一致,防止后续演进产生口吻漂移。
测试覆盖完整(213 passed),无 blocking 问题,可以合并。
注:关于 PR 中提到的 示例代码块与 常量不一致的问题,建议后续单独开 PR 同步。
xnne-bot
left a comment
There was a problem hiding this comment.
刚才对代码做了一次更严苛的边界检查,发现了一个可能击穿 ValueError 导致 Agent 循环直接崩溃的 TypeError 隐患。
隐患代码
在 src/wikimem/memorize.py 的 handle_diary_tool 中:
unexpected = sorted(set(parsed) - {"content"})
if unexpected:
raise ValueError(
f"diary tool call has unexpected argument(s): {', '.join(unexpected)}; "
"only 'content' is accepted (the host stamps time and provenance)"
)坑点与崩溃路径
因为 handle_diary_tool 的参数 args 允许传入宿主解析好的 dict,如果传入的 dict 含有非 str 类型的 key(例如 {1: "foo"} 或混合类型 {1: "foo", "date": "yesterday"}):
sorted()阶段崩溃:在 Python 3 中,如果 key 类型混合(如包含int和str),sorted()会直接抛出TypeError: '<' not supported between instances of 'str' and 'int'。join()阶段崩溃:即使 key 只有一种非 str 类型(如整型),在', '.join(unexpected)时,也会因为join无法拼接非字符串元素而抛出TypeError: sequence item 0: expected str instance, int found。
由于这两个阶段抛出的都是 TypeError,直接违背了“凡是模型数据非法一律只抛 ValueError 方便宿主捕获”的设计意图,会导致宿主的 except ValueError 无法拦截,直接将 Agent 循环打挂。
修复建议
在提取 unexpected keys 时,强制转换为 str 并进行排序/拼接:
unexpected = sorted(str(k) for k in set(parsed) - {"content"})这样既避免了混合类型排序崩溃,也避免了 join 拼接崩溃,将所有数据校验问题稳稳收敛在 ValueError 内。
评审在边界检查里发现的真问题,已复现:
handle_diary_tool(diary, {1: "foo"})
-> TypeError: sequence item 0: expected str instance, int found
handle_diary_tool(diary, {1: "foo", "date": "yesterday"})
-> TypeError: '<' not supported between instances of 'str' and 'int'
`sorted(set(parsed) - {"content"})` 里,混合类型 key 会让 `sorted` 抛
TypeError,非 str 的 key 会让后面的 `join` 抛 TypeError。两者都绕过宿主的
`except ValueError`,直接把 Agent 循环打挂 —— 而"一切非法数据只抛 ValueError"
正是这个函数存在的意义。修法采纳评审建议:排序与拼接前先 `str(k)`。
补充一点边界:这条路**只能**从宿主自建的 dict 进来。JSON 对象的 key 一定是
字符串,所以 `args` 走 JSON 字符串那条路进不来(已实测)。但这不构成不修的
理由 —— 我在 PR 描述和代码注释里都声称过这个不变式是全称的,那它就必须是。
顺手把整个函数扫了一遍:22 种畸形输入现在全部收敛为 ValueError,且都没有写
任何东西到盘上。唯一剩下的 TypeError 是宿主把 `**append_kwargs` 拼错(如
`dat=`),那是**调用方**的 bug 不是模型的,本就该以 TypeError 暴露。
测试:216 passed, 1 skipped(新增 3 条参数化用例,覆盖三种 key 形状)。
docstring 里把「所有畸形入参一律 ValueError」这条契约显式写了出来,附
宿主侧的 try/except 写法。
Co-Authored-By: Claude <noreply@anthropic.com>
|
这条是真 bug,谢谢。已复现、已修( 复现两条崩溃路径与你描述的完全一致,都实测到了: 三条全部绕过 unexpected = sorted(str(k) for k in set(parsed) - {"content"})一处需要补充的边界(不影响该修,但影响它的严重性判断)这条路只能从宿主自建的 dict 进来。JSON 对象的 key 按规范一定是字符串: >>> [type(k).__name__ for k in json.loads('{"1": "x", "a": 2}')]
['str', 'str']所以 但这不构成不修的理由,反而是必须修的理由:我在 PR 描述和代码注释里都把「一切非法入参只抛 ValueError」当作全称不变式来卖。一个我自己声称是全称的性质,不能只在大多数情况下成立 —— 否则文档就是错的,而宿主恰恰会照着它只写一个 顺手把整个函数扫了一遍不只修这两处,我把 22 种畸形输入全过了一遍,确认没有第三条漏网路径:
22/22 全部收敛为 唯一剩下的 TypeError 是宿主把 handle_diary_tool(d, {"content": "ok"}, dat="2026-07-30") # 少个 e
-> TypeError这条我刻意留着:那是调用方代码写错了,不是模型数据非法,本就该以 TypeError 炸出来给宿主开发者看,而不是伪装成一条"模型说错话了"混进 Agent 的重试循环里。 其余变更
四道闸门:216 passed, 1 skipped(原 213 + 3),ruff format / ruff check / ty 全绿。 另: |
xnne-bot
left a comment
There was a problem hiding this comment.
经再次评估,该 PR 完全没有超出职责范围,职责边界控制得极其严良。
评估依据
-
改动范围高度收敛:
GitHub 上的真实改动仅涉及 7 个文件,全部集中在memorize.py、__init__.py、对应的单元测试以及使用说明文档中。没有波及到检索(retrieval.py)或底层的存储/向量缓存模块。 -
主动克制了 Scope Creep(范围蔓延):
作者在 PR 描述中提到了发现docs/guide/writing-diary.md与DIARY_PROMPT常量之间存在历史漂移,但明确指出“不在本 PR 里改”,而是保持当前 PR 的单一职责。这说明作者对改动边界有清晰的自控力。 -
单元测试职责单一:
新增的测试用例(如独立 Schema 校验、错误提示内容比对、文风规则一致性断言等)全部精准服务于 Mode B(Agent 工具模式)本身,没有夹带任何不相关的重构。
结论:无任何越界或多余改动,职责清晰,符合合并标准。
xnne-bot
left a comment
There was a problem hiding this comment.
已确认最新提交 6e89c7c 的修复效果:
- 彻底解决 TypeError 隐患:非 string 类型的 key 无论单类型还是混合类型,现在都能被
str(k)正确处理并转换为字符串进行排序与拼接,错误被稳稳收拢在ValueError异常中。 - 测试全面覆盖:新增的测试用例均正常运行通过。
- 职责和范围控制克制:无多余的代码蔓延。
现已完全具备合并条件。因 gh 客户端为机器人身份,此处以 COMMENT 形式明确:可以合并。
#36 合并后顺手核对了一遍项目自己的状态声明,发现落后现实**两个里程碑**: | 文件 | 声称 | 实际 | |---|---|---| | README.md | **M3(当前)**,M4 CLI 是将来时 | CLI 在 #12 就落地了 | | README.zh-CN.md | 同上 | 同上 | | docs/guide/what-is-wikimem.md | M4 CLI「(next)」 | 同上 | | docs/zh/guide/what-is-wikimem.md | M4「(下一步)」 | 同上 | 另外两处:README 与 guide 之间**互相矛盾**(一个说 M3 是当前、一个说 M3 已完成); 而 **M5(日记 + 时间门控)—— 本仓库迄今最大的一块工作 —— 四份状态清单里一次 都没出现过**。 改动: - M3/M4 标 ✅,补上 M5(#16 #17 #19 + #26 #27 #31),补上 M6 = serve(暂缓)。 M5 / M6 这两个编号不是我编的:ADR-0001、ADR-0002 的「实施」段就把日记与时间 门控划为 M5,ADR-0004 把 serve 划为 M6。 - 四份清单末尾统一加一句:**M5 之后按决策跟踪、不按里程碑**,指向 ADR 索引。 这条是防复发的 —— 里程碑清单本来就不再是这个项目的状态载体了,让它继续假装 是,下一条 ADR 落地时它就会再次过期。 - 同步 ADR-0005:模式 B 已由 #36 落地,实施行与索引行从「⚠️ 模式 B 未做」 改为「✅ 模式 A #24、模式 B #36」。这是我在 #35 里承诺合并后补的那一处。 CLI 确实存在,不是照 PR 记录推断的 —— `uv run wikimem --help` 五个子命令 (ls/show/grep/explain/graph)都在。文档站本地 `pnpm build` 通过(VitePress 默认对死链报错,因此新加的 `/adr/README` 链接是通的;该路径线上实测 200, `/adr/` 反而是 404)。 Co-authored-by: MrXnneHang <xnnehang@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
起因是 #36 里报的「文档块与 `DIARY_PROMPT` 常量不一致(933 vs 1049 字符)」。 真去查的时候发现不止是文案漂移 —— **文档给出的提示词,按文档自己说的用法用, 会直接抛异常**: memorize(diary, turn, llm=..., prompt=<文档里那份>) EN 文档块 -> KeyError: 'conversation_turn' zh 文档块 -> KeyError: 'conversation_turn' DIARY_PROMPT -> 正常 两个各自独立的原因,任何一个单独出现都足以让它崩: 1. **`{conversation_turn}` 占位符** —— `memorize()` 只 `.format(character=...)`, 从不填这个键;那一轮对话是作为**独立的 user 消息**发出的,根本不进提示词。 2. **JSON 示例的花括号没转义** —— `[ { "content": … } ]` 被 `.format` 当成字段 名,报 `KeyError: ' "content"'`。常量里写的是 `[{{"content": …}}]`。 也就是说:照文档复制一份提示词的读者,拿到的不是日记,是一个崩溃。 改动: - **EN 页**:文档块换成 `DIARY_PROMPT` 逐字原文,并补两段说明 —— `{character}` 与 `{{ }}` 是 `.format` 的产物(后者渲染成 `{ }`),以及**对话本身不会被插进 提示词**(system + user 两条消息)。原来那段错误的 `The turn:` 由此消失。 - **zh 页**:那份中文提示词是**改写版**、不是随包那份的副本,所以保留中文,但 修掉同样的两个毛病,并把规则与常量逐条对齐(6 条对 6 条)。措辞也改准:以前 说"英文版同一份文案随包提供",实际是"随包的是它的英文原版"。 测试(4 条新增,均已验证会在修复前的文档上失败): - EN 文档块与 `DIARY_PROMPT` **逐字节相等**。散文管不住这种漂移,只有等号能。 - **两份文档提示词都真的跑一遍** `memorize(prompt=...)` —— 这条才是能抓到本次 这类 bug 的那条。zh 是改写版没法比等号,但照样能执行。 - zh 与常量的规则条数一致(翻译版能做到的最强约束)。 四道闸门:220 passed, 1 skipped(原 216 + 4);文档站 `pnpm build` 通过。 Co-authored-by: MrXnneHang <xnnehang@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
动机
ADR-0005 承诺两种宿主驱动的写入模式,但落地的只有模式 A(后台抽取
memorize(),#24)。模式 B —— §3 那句"角色在对话里主动说『哇,这个我帮你记下来』" —— 一直是张空头支票。这不只是补齐清单。两种模式服务的是两种真实形态:后台记(无感、不打断)与角色主动记(有感、"角色选择了记住这件事")。ADR 的理由里写得很直白:陪伴场景两者都要。
解决方案
handle_diary_tool()里没有任何 LLM 调用:Agent 本身就是那个模型,正文是它写回复时自己写的,框架只做校验与 append。这就是模式 B 在"每轮 ≤ 1 次 LLM 调用"(XnneHangLab ADR-0001 硬约束 2)里仍然成立的原因。测试里直接钉住了这点 ——handle_diary_tool的签名里没有llm参数。三个想请你看一眼的取舍
① schema 只暴露
content,不给date。模型没有时钟。它填的日期只能是猜的,而猜错的那条会落在错误的一天,且看上去完全合理 —— 正是这个仓库反复抓到的那类静默错误。所以
date/time/owner一律由宿主以关键字传入,与模式 A 的"模型写什么、宿主 stamp 何时"同一条分工。顺带一个推论:多出来的参数是拒绝,不是忽略。 如果模型无视
additionalProperties: false塞了个date进来,静默丢弃意味着"模型以为记在昨天、实际记在今天",没有任何人会发现。② 它抛异常,而
memorize()fail-open —— 刻意相反。两边的"空"意思正好相反:
memorize()[]ValueError后者"悄悄成功"才是最坏结果。异常消息是按「可以直接回传给 Agent 当 tool 结果」来写的:
Agent 拿到这句能在同一轮里自我纠正。
另外,所有 malformed 情形都抛同一种
ValueError(含两处# noqa: TRY004,注释里写了理由)。ruff 建议这两处改TypeError,我没听:值是从模型那边过来的、不是调用方代码写错;更重要的是宿主只会写一个except ValueError,第二种异常类型会直接穿过去把 Agent 循环打挂。这条如果你觉得该听 ruff 的,我改。③ description 与
DIARY_PROMPT共用同一套文风规则(ADR-0005 §4「同一份参考提示词服务两者」)。两份手工维护的文风规则一定会漂移,而这里的漂移是看不见的 —— 只会表现为两种模式写出两种口吻。所以加了一条测试逐行比对:
DIARY_TOOL_DESCRIPTION里的 5 条规则必须逐字出现在DIARY_PROMPT中。我实测过它会失败:把工具描述里的- One event per entry. Be concrete and specific.改成Be very concrete,该测试立刻红。验证
四道闸门全绿(与 CI 同命令,本地跑):
文档是执行过的,不是只 grep 过。 把《写日记》里那段 Agent 循环原样抽出来跑了一遍(只 stub 掉 provider 客户端),另外两段也跑了:
diary/2026-07-30.md,journal 也记了;input_schema;文档 EN + zh 同步:《写日记》两页各加了模式 B 的完整 Agent 循环 + 三个取舍的说明,api reference 两页补了条目。CJK 锚点按仓库既有写法(
#另一种写法-角色自己动手,同#边界-一跳-是刻意的)。git diff main --stat:7 个文件,+564 / -13,其中 doc 4 个、code 2 个、test 1 个。类型