给自己的 todos 工具
前面几集用到的文档,都是包内置的:pi.live、pi.inbox、pi.usage、pi.provider、pi.agent。这一集自己定义一个文档,再围绕它做一个工具。
目标很小:给 agent 一个 todos 列表。模型说“记住,要修构建”,工具把这句话写进 todos;下一次请求,系统提示词里就带着这份列表。
数据不放在记录本里,而是放在一个自定义 document 里。
图 12-1 · entry 记“说过什么”,document 记“现在是什么”
为什么不直接写进对话
Section titled “为什么不直接写进对话”最简单的方法,是让模型把待办写进普通回答,或者塞进一条 pi.note 条目。但这样有两个问题:
- 条目只追加、不可变。要勾掉一项、改一项,只能再写一条,列表会越来越难读。
- 模型每次都要从历史里重新拼出“现在还有哪些待办”,很容易漏。
document 不一样。它是挂在对话旁边的一块可变状态,有类型、有版本、有初始值;改动可以和其他写入(包括条目)放进同一个 commit,原子落盘。工具改它,section 读它,模型每次看到的是“当前列表”,不是一堆历史。
定义 Todos 文档
Section titled “定义 Todos 文档”先定义文档。包里第 10 个示例就是这么做的:
源码对照 · packages/durable/test/examples/10-extension-state.ts:23
const Todos = defineDoc<{ items: string[] }>({ checkpointWhen: () => false, kind: "example.todos", version: 1, scope: "conversation", history: "rewindable", fork: "asOf", initial: () => ({ items: [] }),});逐项看:
kind:持久化的类型名,写进documents.kind。换个名字就是另一种文档。version:值结构的版本。以后字段变了,靠migrate迁移。scope: "conversation":每个对话一份。history: "rewindable":保留历史,可以按时间点读旧值。fork: "asOf":fork 出来的子对话,从 fork 那一刻的 todos 开始。initial:第一次创建时的值。checkpointWhen:必填。返回true时把这次改动存成完整 base,用来限制 delta 链长度;普通文档用() => false就行。
defineDoc 的这几个字段,来自文档语义和公共定义:
源码对照 · packages/durable/src/types.ts:43
export type LatestConversationSemantics = { readonly scope: "conversation"; readonly history: "latest"; readonly fork: "current" | "initial";};
export type RewindableConversationSemantics = { readonly scope: "conversation"; readonly history: "rewindable"; readonly fork: "asOf" | "current" | "initial";};源码对照 · packages/durable/src/types.ts:70
export type CommonDocDefinition<T extends JsonObject> = { readonly kind: string; readonly version: number; initial(): T; migrate?(value: JsonObject, fromVersion: number): T; checkpointWhen(value: Readonly<T>, ops: readonly Op[], info: CheckpointInfo): boolean;};不需要在创建对话时提前建这个文档。第一次 tx.doc(Todos, ...) 写入时,它才出现;没写过时,snapshot 返回 undefined,当作空列表就行。
图 12-2 · kind、version、scope、history、fork、initial 和必填的 checkpointWhen
写一个 todo 工具
Section titled “写一个 todo 工具”文档定义好,工具就简单了:
源码对照 · packages/durable/test/examples/10-extension-state.ts:36
const Todo = defineExtension({ name: "todo", tools: [ defineTool({ name: "todo", description: "Add an item to your todo list", parameters: obj({ item: str() }), execute: async (args, api, callContext) => { await api.commit(async (tx) => { (await tx.doc(Todos, api.conversationId)).items.push(args.item); }, callContext); return { output: [{ type: "text", text: `added ${args.item}` }] }; }, }), ], sections: [ section("todos", async (input, renderContext) => { const todos = await input.read.snapshot(Todos, input.conversationId, renderContext); return todos === undefined || todos.items.length === 0 ? undefined : todos.items.join("\n"); }), ],});这段里有三件事:
- 工具参数:
obj({ item: str() }),模型要给一个字符串。 - 写文档:
api.commit里tx.doc(Todos, api.conversationId)拿到这个对话的文档,push 一项。这次 commit 把文档改动原子地存下来;工具返回后,工具结果再由任务写进记录本。文档先落盘,结果后落盘,两者不是同一个 commit。 - 读文档:
section("todos", ...)在每次请求前用input.read.snapshot读当前值;没有文档或列表为空时返回undefined,这一段就不出现在系统提示词里。
工具的 commit 用的是 ToolExecutionApi.commit:
源码对照 · packages/durable/src/harness/types.ts:283
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context, options?: CommitOptions): Promise<T>;它和 root.commit() 是同一种 commit;区别只是它绑在当前工具调用上。文档改动在这个 commit 里原子落盘;工具结果由工具任务在 execute 返回后另写一次。
图 12-3 · api.commit 写 tx.doc(Todos);工具结果随后由任务落盘,不是同一个 commit
接上 Harness
Section titled “接上 Harness”把扩展装进注册表,打开 Harness:
const registry = createRegistry();registry.install(Todo);
const harness = await Harness.open(new MemoryStorage(), { models, registry }, context);const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } },});示例用 MemoryStorage 方便跑;换成 SQLite 或 JSONL 存储,文档的写法不变。
然后让模型说一句“记住,要修构建”。示例里的 faux 模型会先发一个 todo 工具调用,再回答“Noted.”。工具跑完,文档里就有了第一项:
源码对照 · packages/durable/test/examples/10-extension-state.ts:73
console.log("todos:", await harness.snapshot(Todos, root.id, context));这时打印出来是:
todos: { items: [ 'fix the build' ] }注意,第一次请求的系统提示词里还没有 todos 列表。那时文档还没创建,section 返回 undefined;工具结果本身会告诉模型“added fix the build”。
下一次请求就不一样了。section 在准备请求时读到列表,把它写进系统提示词:
源码对照 · packages/durable/test/examples/10-extension-state.ts:75
// The next request's system prompt carries the list.await (await root.submit({ type: "input", content: "What is next?" }, context)).wait(context);const { messages } = await root.context(context);示例会打印出系统消息里的 sections,其中就有 todos 这一段。也就是说:
- 工具负责写;
- section 负责在下一次请求前读;
- 模型看到的不是“历史里有过一条 todo”,而是“现在还剩哪些 todo”。
图 12-4 · 工具负责写;section 在下一次请求前 snapshot 读,放进系统提示词
为什么用 document,不用 entry
Section titled “为什么用 document,不用 entry”把 todos 放进 document,有几个直接的好处:
- 可变:
items.push()、删除、勾选,都是改当前值,不用往记录本里追加一条“我改了什么”。 - 原子:文档改动在一个 commit 里要么全写、要么不写;工具结果随后由任务落盘。
- 可快照:
harness.snapshot(Todos, conversationId, context)直接读当前值,调试和 UI 都方便。 - 可 fork:
history: "rewindable"+fork: "asOf",子对话从 fork 点的列表开始,不会把父对话后来的改动带过去。 - 可迁移:
version和migrate让文档结构升级有路可走。
注意:工具默认 replay 是 unsafe。如果文档写完、结果还没落盘就崩溃,恢复时不会重跑这个工具,文档里可能已经有这一项,但模型收到的是 interrupted。需要严格一次的工具要自己做幂等,或者把“写入”和“检查”设计成可重入的。
entry 适合“说过什么”,document 适合“现在是什么”。todos 是后者。
fork 和缺失文档
Section titled “fork 和缺失文档”fork: "asOf" 的意思是:fork 发生在哪个 entry,子对话就拿父对话在那个 entry 的文档值。之后父子各改各的,互不影响。
如果子对话在 fork 时还没有 todos,snapshot 会返回 undefined;section 把它当空列表。第一次写入时才创建文档,这和第 10 个示例的注释一致:
源码对照 · packages/durable/test/examples/10-extension-state.ts:33
// The tool writes the document; the section shows it to the model before every// request. Nothing needs to create it up front: tx.doc() creates it on first// write, and the section treats a missing document as an empty list.这也是自定义文档比“自己找一张表存”省事的地方:不需要建表、不需要在对话创建时初始化;文档地址和生命周期由 Durable 管。
图 12-5 · 子对话从 fork 点的 todos 开始;没写过时 snapshot 返回 undefined,当空列表
想做 UI,就 watch 这个文档
Section titled “想做 UI,就 watch 这个文档”harness.watchDoc() 可以观察一个文档地址。工具写完、commit 发布后,watch 会收到新值;UI 不用轮询。
这和 pi.live 的 watch 是同一条路:文档改动先落盘,再发布。todos 不是流式进度,直接用普通 commit 就行,不需要 deferred。
这一集记住三句话:
- todos 是“当前状态”,不是“说过的话”。 所以放 document,不放 entry。
- 工具写文档,section 读文档。 写入和工具结果在同一个 commit;读取发生在下一次请求前。
- 文档按对话隔离,fork 按 fork 点复制。
scope: "conversation"、history: "rewindable"、fork: "asOf"决定了它的生命周期。
会定义文档之后,很多“agent 需要记住的东西”都可以照这个模式做:待办、偏好、草稿、项目配置、每个对话的工作目录。工具负责改,section 负责给模型看,Durable 负责把两者原子地存下来。