跳转到内容

给自己的 todos 工具

前面几集用到的文档,都是包内置的:pi.live、pi.inbox、pi.usage、pi.provider、pi.agent。这一集自己定义一个文档,再围绕它做一个工具。

目标很小:给 agent 一个 todos 列表。模型说“记住,要修构建”,工具把这句话写进 todos;下一次请求,系统提示词里就带着这份列表。

数据不放在记录本里,而是放在一个自定义 document 里。

图 12-1 todos 放在对话旁边的文档里

图 12-1 · entry 记“说过什么”,document 记“现在是什么”

最简单的方法,是让模型把待办写进普通回答,或者塞进一条 pi.note 条目。但这样有两个问题:

  • 条目只追加、不可变。要勾掉一项、改一项,只能再写一条,列表会越来越难读。
  • 模型每次都要从历史里重新拼出“现在还有哪些待办”,很容易漏。

document 不一样。它是挂在对话旁边的一块可变状态,有类型、有版本、有初始值;改动可以和其他写入(包括条目)放进同一个 commit,原子落盘。工具改它,section 读它,模型每次看到的是“当前列表”,不是一堆历史。

先定义文档。包里第 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 先定义一张 todos 文档

图 12-2 · kind、version、scope、history、fork、initial 和必填的 checkpointWhen

文档定义好,工具就简单了:

源码对照 · 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");
}),
],
});

这段里有三件事:

  1. 工具参数:obj({ item: str() }),模型要给一个字符串。
  2. 写文档:api.commit 里 tx.doc(Todos, api.conversationId) 拿到这个对话的文档,push 一项。这次 commit 把文档改动原子地存下来;工具返回后,工具结果再由任务写进记录本。文档先落盘,结果后落盘,两者不是同一个 commit。
  3. 读文档: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 工具写文档

图 12-3 · api.commit 写 tx.doc(Todos);工具结果随后由任务落盘,不是同一个 commit

把扩展装进注册表,打开 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 下一次请求,模型看到当前列表

图 12-4 · 工具负责写;section 在下一次请求前 snapshot 读,放进系统提示词

把 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: "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 按 fork 点复制

图 12-5 · 子对话从 fork 点的 todos 开始;没写过时 snapshot 返回 undefined,当空列表

harness.watchDoc() 可以观察一个文档地址。工具写完、commit 发布后,watch 会收到新值;UI 不用轮询。

这和 pi.live 的 watch 是同一条路:文档改动先落盘,再发布。todos 不是流式进度,直接用普通 commit 就行,不需要 deferred。

这一集记住三句话:

  1. todos 是“当前状态”,不是“说过的话”。 所以放 document,不放 entry。
  2. 工具写文档,section 读文档。 写入和工具结果在同一个 commit;读取发生在下一次请求前。
  3. 文档按对话隔离,fork 按 fork 点复制。 scope: "conversation"、history: "rewindable"、fork: "asOf" 决定了它的生命周期。

会定义文档之后,很多“agent 需要记住的东西”都可以照这个模式做:待办、偏好、草稿、项目配置、每个对话的工作目录。工具负责改,section 负责给模型看,Durable 负责把两者原子地存下来。