Skip to content

✨ feat: 日记 Agent 工具 —— 角色当场记一笔,零 LLM 调用(ADR-0005 模式 B) - #36

Merged
MrXnneHang merged 2 commits into
mainfrom
feat/adr0005-mode-b-diary-tool
Jul 31, 2026
Merged

✨ feat: 日记 Agent 工具 —— 角色当场记一笔,零 LLM 调用(ADR-0005 模式 B)#36
MrXnneHang merged 2 commits into
mainfrom
feat/adr0005-mode-b-diary-tool

Conversation

@xnne-bot

Copy link
Copy Markdown
Collaborator

动机

ADR-0005 承诺两种宿主驱动的写入模式,但落地的只有模式 A(后台抽取 memorize()#24)。模式 B —— §3 那句"角色在对话里主动说『哇,这个我帮你记下来』" —— 一直是张空头支票。

这不只是补齐清单。两种模式服务的是两种真实形态:后台记(无感、不打断)与角色主动记(有感、"角色选择了记住这件事")。ADR 的理由里写得很直白:陪伴场景两者都要。

解决方案

diary_tool()                       # -> append_diary(content) 的 function-call schema
handle_diary_tool(diary, args)     # -> 校验 + 落盘,返回 DiaryItem

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() 这一轮没什么值得记的 —— 健康的常态 fail-open,返回 []
tool call 角色刚宣布要记下来,结果没落地 ValueError

后者"悄悄成功"才是最坏结果。异常消息是按「可以直接回传给 Agent 当 tool 结果」来写的:

diary tool call has unexpected argument(s): date; only 'content' is accepted
(the host stamps time and provenance)

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,该测试立刻红。

顺带发现的一个已有问题(不在本 PR 里改)docs/guide/writing-diary.md 声称"The same text ships as wikimem.DIARY_PROMPT",但那个 ```text 块与常量已经不是同一份文案了(933 vs 1049 字符,措辞和规则条目都不同)—— 应该是 #22 写文档、#24 改了常量却没回头同步。中文页同样。这正是取舍 ③ 想防的那类漂移,只是它已经发生了。要不要我另开一个 PR 修?

验证

四道闸门全绿(与 CI 同命令,本地跑):

uv run pytest -q                   → 213 passed, 1 skipped   (基线 196,新增 17 条)
uvx ruff format --check .          → 27 files already formatted
uvx ruff check .                   → All checks passed!
uv run ty check --error-on-warning → All checks passed!

文档是执行过的,不是只 grep 过。 把《写日记》里那段 Agent 循环原样抽出来跑了一遍(只 stub 掉 provider 客户端),另外两段也跑了:

  • 完整循环 → 条目真的落到了 diary/2026-07-30.md,journal 也记了;
  • Anthropic 形状的 reshape 片段 → 产出合法的 input_schema
  • 文档里贴的那句报错 → 与真实抛出的消息逐字相同(用 assert 比对的,不是眼看的)。

文档 EN + zh 同步:《写日记》两页各加了模式 B 的完整 Agent 循环 + 三个取舍的说明,api reference 两页补了条目。CJK 锚点按仓库既有写法(#另一种写法-角色自己动手,同 #边界-一跳-是刻意的)。

git diff main --stat:7 个文件,+564 / -13,其中 doc 4 个、code 2 个、test 1 个。

类型

  • ✨ feat: 添加新功能

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 xnne-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

代码实现和设计非常干净,三个取舍点的考虑很到位:

  1. Schema 隐藏时间与溯源字段:由宿主统一 stamp Thu Jul 30 03:10:32 PM UTC 2026//,避免模型伪造时间导致静默错误。
  2. **校验失败显式抛 **:区分了 的 fail-open 逻辑,工具调用失败直接抛出友好 Error 消息便于 Agent 循环自愈。
  3. 文风规则一致性断言:在测试里锁定了 与 的规则逐字一致,防止后续演进产生口吻漂移。

测试覆盖完整(213 passed),无 blocking 问题,可以合并。

注:关于 PR 中提到的 示例代码块与 常量不一致的问题,建议后续单独开 PR 同步。

xnne-bot

This comment was marked as duplicate.

@xnne-bot xnne-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

刚才对代码做了一次更严苛的边界检查,发现了一个可能击穿 ValueError 导致 Agent 循环直接崩溃的 TypeError 隐患

隐患代码

src/wikimem/memorize.pyhandle_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"}):

  1. sorted() 阶段崩溃:在 Python 3 中,如果 key 类型混合(如包含 intstr),sorted() 会直接抛出 TypeError: '<' not supported between instances of 'str' and 'int'
  2. 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>
@xnne-bot

Copy link
Copy Markdown
Collaborator Author

这条是真 bug,谢谢。已复现、已修(6e89c7c)。

复现

两条崩溃路径与你描述的完全一致,都实测到了:

handle_diary_tool(d, {1: "foo"})                    -> TypeError: sequence item 0: expected str instance, int found
handle_diary_tool(d, {1: "foo", "date": "yesterday"}) -> TypeError: '<' not supported between instances of 'str' and 'int'
handle_diary_tool(d, {"content": "ok", 2: "x"})     -> TypeError: sequence item 0: expected str instance, int found

三条全部绕过 except ValueError。修法用你给的写法:

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']

所以 args 走 JSON 字符串那条路(OpenAI 的 tool_call.function.arguments)永远进不来,走 provider 解析好的 dict(Anthropic 的 input)也进不来 —— 那也是 JSON 解出来的。真正能触发的是宿主手工构造 dict 并塞了非 str key。

但这不构成不修的理由,反而是必须修的理由:我在 PR 描述和代码注释里都把「一切非法入参只抛 ValueError」当作全称不变式来卖。一个我自己声称是全称的性质,不能只在大多数情况下成立 —— 否则文档就是错的,而宿主恰恰会照着它只写一个 except

顺手把整个函数扫了一遍

不只修这两处,我把 22 种畸形输入全过了一遍,确认没有第三条漏网路径:

类别 覆盖
字符串路径 坏 JSON、null、数组、裸字符串、数字
key 类型 int / mixed / tuple / None / bool / float
content 缺失、5None["a"]
额外参数 一个、两个、与合法 content 并存
args 本身 []None5object()

22/22 全部收敛为 ValueError,且全部没有向磁盘写入任何东西。

唯一剩下的 TypeError 是宿主把 **append_kwargs 拼错:

handle_diary_tool(d, {"content": "ok"}, dat="2026-07-30")   # 少个 e
-> TypeError

这条我刻意留着:那是调用方代码写错了,不是模型数据非法,本就该以 TypeError 炸出来给宿主开发者看,而不是伪装成一条"模型说错话了"混进 Agent 的重试循环里。

其余变更

  • 新增 3 条参数化用例,覆盖三种 key 形状(int-only / mixed / 合法 content + 垃圾 key)。
  • docstring 里把「所有畸形入参一律 ValueError」这条契约显式写出来,并附宿主侧的 try/except 写法 —— 你这次是在探这个契约,那它就该写在契约里,而不是只活在 PR 描述里。同时注明 **append_kwargs 拼错仍是 TypeError 及其理由。
  • 那行 str(k) 加了注释说明它是承重的,不是防御性噪音,免得日后被当成多余的类型转换删掉。

四道闸门:216 passed, 1 skipped(原 213 + 3),ruff format / ruff check / ty 全绿。


另:docs/guide/writing-diary.md 示例块与 DIARY_PROMPT 常量不一致那条,按你说的另开 PR 处理,不塞进本 PR。

@xnne-bot xnne-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

经再次评估,该 PR 完全没有超出职责范围,职责边界控制得极其严良。

评估依据

  1. 改动范围高度收敛
    GitHub 上的真实改动仅涉及 7 个文件,全部集中在 memorize.py__init__.py、对应的单元测试以及使用说明文档中。没有波及到检索(retrieval.py)或底层的存储/向量缓存模块。

  2. 主动克制了 Scope Creep(范围蔓延)
    作者在 PR 描述中提到了发现 docs/guide/writing-diary.mdDIARY_PROMPT 常量之间存在历史漂移,但明确指出“不在本 PR 里改”,而是保持当前 PR 的单一职责。这说明作者对改动边界有清晰的自控力。

  3. 单元测试职责单一
    新增的测试用例(如独立 Schema 校验、错误提示内容比对、文风规则一致性断言等)全部精准服务于 Mode B(Agent 工具模式)本身,没有夹带任何不相关的重构。

结论:无任何越界或多余改动,职责清晰,符合合并标准。

@xnne-bot xnne-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已确认最新提交 6e89c7c 的修复效果:

  1. 彻底解决 TypeError 隐患:非 string 类型的 key 无论单类型还是混合类型,现在都能被 str(k) 正确处理并转换为字符串进行排序与拼接,错误被稳稳收拢在 ValueError 异常中。
  2. 测试全面覆盖:新增的测试用例均正常运行通过。
  3. 职责和范围控制克制:无多余的代码蔓延。

现已完全具备合并条件。因 gh 客户端为机器人身份,此处以 COMMENT 形式明确:可以合并。

@MrXnneHang
MrXnneHang merged commit 4184daa into main Jul 31, 2026
6 checks passed
@MrXnneHang
MrXnneHang deleted the feat/adr0005-mode-b-diary-tool branch July 31, 2026 02:48
MrXnneHang added a commit that referenced this pull request Aug 1, 2026
#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>
MrXnneHang added a commit that referenced this pull request Aug 2, 2026
起因是 #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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants