夜雨聆风学习资料网

ARTICLE · 1163062

DeepSeek-Harness 源码深读(20):待办清单没有 id,改一项要重写全表

DeepSeek-Harness 源码深读(20):待办清单没有 id,改一项要重写全表

摘要:todo_write 看着像数据库接口,却没有 add/remove/update:一次调用整表替换,条目无 id,工具零状态,execute 只往会话日志追加一个事件。并行纪律是 required 布尔,描述文案和运行时校验双层强制;invariant 却故意不管并行数,因为换策略后的历史日志必须还能回放。packages/todo/tool-todo 逐段拆。

待办清单这个功能小到很多框架只给它一段示例代码,dsh 为它单开了一个包,packages/todo/tool-todo,外加一个持久不变量伴随插件。深读第 14 篇《工具管线只有一条,调度器却写了两个》拆的是工具怎么被调度,这一篇换视角:挑一个工具本体,从模型看到的契约,一路拆到写进日志之后谁替它守规矩。

我们挑的样本是 todo_write,模型自我规划的进度表。它的执行逻辑短到一眼见底,恰好适合看清楚「一个工具到底是什么」。

一、没有 add,没有 update,没有 remove

先看模型侧的契约。假设由你来设计待办工具,第一反应大概率是一组接口:todo_add 加一条,todo_update 改一条,todo_remove 删一条,条目带 id,改哪条传哪条。我们拿这个直觉设计当对照组,看 dsh 怎么走另一条路。

工具描述的第一段就把话说死(index.ts:45-49):

const DESCRIPTION_HEAD =  'Record and update a structured task list for the current work. Send the ENTIRE '  + 'list every call — it REPLACES the previous list (there are no partial updates, '  + 'no per-item edits). Use it to plan multi-step work and show progress: add one '  + 'todo per concrete step before you start. '

这段是模型看到的工具说明开头,大写是原文自带。注意看两个词:ENTIRE、REPLACES。每次调用发完整清单,本次整表覆盖上一次,没有局部更新,没有逐条编辑。

主线案例从这里开始。模型接到「修掉 lint 报错」,第一次调用写下三步:跑 lint(pending)、改报错(pending)、复跑验证(pending)。lint 跑完,它第二次调用不是 update 第一条,而是把整张表重发:跑 lint(completed)、改报错(in_progress)、复跑验证(pending)。改一项,重发三项。清单涨到 30 项时,改 1 项也是重发 30 项。这笔账先记下,第七节一起算。

为什么把增删改全删了。增量接口要求模型构造可靠的 patch:记住每条的 id,别用错版本,别制造冲突。这三件事都是语言模型的弱项,id 会幻觉,版本号会漏。整表替换把「构造 patch」换回「生成快照」,后者是模型的舒适区。快照还天然幂等:同一张表重复应用,结果不变;回放日志时每条 todo/write 都是完整真相,不用追着补丁链重建。

条目身份随之消失。TodoItem 的定义(core/session/src/types.ts:189-192):

export interface TodoItem {  /** What this task is — a short imperative line shown in the UI. */  content: string  /** Lifecycle state. `in_progress` marks a task being worked now; parallel work may mark several. */  status: 'pending' | 'in_progress' | 'completed'}

这段是清单条目的全部字段。注意看定义上方的源码注释:No id, priority, or activeForm — the list is replaced wholesale on every write, so entries need no stable identity。没有 id、优先级、进行态长文案,整表替换之下条目不需要稳定身份。这条推论先于任何实现代码,落在类型定义上。

二、工具零状态:execute 只追加一个事件

表的设计定了,下一个问题:状态存在哪。工具自己持有?execute 给出答案(index.ts:206-213):

    execute(args, exec) {      const todos = toTodoList(args.todos, allowParallel)      if (!exec.agent) {        // The list is per-agent-session state; a non-agent caller (no owning        // session) has nowhere to write it. Reject rather than silently no-op.        throw new Error('todo_write requires an owning agent session')      }      exec.agent.session.append('todo/write', { todos })

这段是工具的全部执行逻辑。注意看成功路径只有一行:exec.agent.session.append('todo/write', { todos }),往调用方 agent 的会话日志追加一个事件,payload 就是整张表。包里搜不到 this.todos,搜不到任何缓存字段,工具对清单零记忆。

会话日志这条线我们在前几篇拆过:《不保存任何会话状态,session 插件凭什么断点续跑》拆了日志本体,《一个订阅都不写,todo 列表和 token 账单从哪来》拆了读侧怎么把清单折出来。这篇补写侧。工具只产生事件;清单的每次变化,都是日志里一条 todo/write。事件契约在 core/session/src/types.ts:303,注释原文 Whole-list snapshot; latest write wins on replay。

清单是 per-agent 的。非 agent 的调用方没有 owning session,代码不静默跳过,直接抛错(index.ts:209-211 注释:Reject rather than silently no-op)。假装成功会骗过调用方;报错把边界摆到明面上。

返回值带三态计数(index.ts:214-222),render 翻成一句 Updated todo list: 2 pending, 1 in progress, 0 completed(index.ts:201-204)。计数是给模型的确认。UI 的清单显示不从这句话来,它从哪来,第六节交代。

三、两层校验:日志快照必须等于模型以为它写的

事件一旦 append 就进持久日志,回放、审计、UI 全信它,写进去的东西必须可信。校验两层,第一层在 schema(index.ts:156-161):

        description: 'The COMPLETE task list, replacing any previous list.',        items: {          type: 'object',          additionalProperties: false,          properties: {            content: { type: 'string', required: true, description: 'What the task is — a short imperative line.' },

这段是参数条目的形状约束。注意看 additionalProperties: false:模型往条目里多塞一个 priority 字段,schema 这关直接失败。为什么不悄悄容忍?toTodoList 的文档注释给了理由(index.ts:85-87):the logged snapshot must equal what the model believes it wrote。日志快照必须等于模型以为自己写的。多余字段照单全收的话,落盘的清单和模型心里的清单开始分叉,审计对不上账。

第二层在值。schema 管形状,管不了空串和重复,toTodoList 补上(index.ts:91-110):

function toTodoList(raw: { content: string; status: string }[], allowParallel: boolean): TodoItem[] {  const todos: TodoItem[] = []  const seen = new Set<string>()  let active = 0  for (const item of raw) {    const content = item.content.trim()    if (content.length === 0) {      throw new Error('invalid todo: `content` must be a non-empty string')    }    if (seen.has(content)) {      throw new Error(`invalid todos: duplicate content ${JSON.stringify(content)}`)    }    // …seen.add、active 计数、todos.push(:103-105)  }  if (!allowParallel && active > 1) {    throw new Error(`invalid todos: at most one task may be in_progress (got ${active})`)  }  return todos}

这段是值校验。注意看三条规矩:content trim 后非空;content 去重,同表重复直接抛错;in_progress 计数压给策略检查。主线案例在这里撞墙:模型想同时改报错和样式,两条都标 in_progress,串行部署下这次调用被拒,错误消息 invalid todos: at most one task may be in_progress (got 2)。模型拿到明错,下次调用自己改回来。

四、并行纪律:一个 required 布尔

allowParallel 从哪来。配置是这个包的全部 Config(index.ts:29-38):

export interface Config {  /**   * Required deployment choice for whether several todos may be `in_progress` at once. True suits   * agents that run work concurrently — subagents, background commands, workflow fan-out …   */  allowParallelInProgress: boolean}

这段是部署侧的显式选择。注意看 required:没有默认值,每个部署必须二选一。注释列举 true 的适用场景:subagents、background commands、workflow fan-out(index.ts:31-35),并发型 agent 允许多条 in_progress;false 恢复串行纪律,同时至多一条。

有默认值会怎样。部署方可能无意中选了不适合自己场景的纪律,问题拖到运行期才暴露。required 把决策逼到组装那一刻。

策略不只运行时强制,还写进模型看的描述。组装函数(index.ts:74-78):

function describe(allowParallel: boolean): string {  return DESCRIPTION_HEAD    + (allowParallel ? DESCRIPTION_PARALLEL : DESCRIPTION_SINGLE)    + DESCRIPTION_TAIL}

这段是描述文案的组装。注意看唯一随策略变化的部分是中间的活跃状态条款:并行时 Mark every todo being actively worked on,几条可同时 in_progress(index.ts:51-55);串行时 AT MOST ONE(index.ts:57-59)。其余文字两种部署一字不差,注释明说 active-status clause is the only part that varies(index.ts:68-71)。

提示词引导加运行时拒绝,双层。只靠提示词,模型偶尔越界;只靠校验,每次越界都浪费一次往返。描述把规矩说在前头,校验兜住漏网的。

五、invariant 故意不管并行数

第四节说运行时会拒绝两条 in_progress。持久层呢?这个包带一个 invariant 伴随插件,挂在日志加载与追加两个口子上。按直觉它应该把并行纪律也断言进去,注释却明写不管(invariant.ts:17-22):

 * Deliberately silent on how many items are `in_progress`. That is the tool's * per-deployment policy (`Config.allowParallelInProgress`), not a durable-shape * rule: a log written while parallel work was allowed must still replay after a * deployment tightens the policy, so tying the invariant to the current config * would reject history that was valid when it was written.

这段是不变量的边界声明。注意看拒绝的理由:日志写下的时刻允许并行,回放它的部署却可能已经收紧。把 invariant 绑到当前配置上,等于用今天的政策否决昨天的历史。

它只管形状(invariant.ts:24-41):

function validateTodos(value: unknown, fail: InvariantFailure): void {  if (!Array.isArray(value)) fail('todo/write todos must be an array')  const seen = new Set<string>()  for (const item of value) {    if (typeof item !== 'object' || item === null) fail('todo/write entries must be objects')    const { content, status } = item as Record<string, unknown>    if (typeof content !== 'string' || content.length === 0 || content.trim() !== content) {      fail('todo/write content must be non-empty and already trimmed')    }    if (seen.has(content)) fail(`todo/write repeats content ${JSON.stringify(content)}`)    // …status 枚举检查(TODO_STATUSES.has)  }}

这段是 durable 校验。注意看查的几样:数组、条目对象、content 非空且已 trim、content 不重复、status 在枚举内。这些在任何部署下恒真,与策略无关。

两层的分界线在哪?toTodoList 拒绝的是本次调用不合当前部署的策略,invariant 拒绝的是日志形状在任何部署下都不成立。前者跟配置走,后者跟数据走。

六、折叠四条:turn/start 清,turn/end 不清

清单读回来靠 todos 投影。从模型调用到 UI 读数,全路径一张图:

两道写入关卡、一条只追加的日志、两个读口

注意看日志条带那行:todo/write 一条接一条,投影读数跟着 last-wins 换表;turn/start 一到清回 null,turn/end 却留着。左上两道关卡是第三节的校验,右下橙格是第五节的 invariant,挂在日志的加载与追加口子上。

框架第九篇拆过,这里只看这个单元自己的折叠规则(index.ts:140-144):

      apply: (state, event) => {        if (event.type === 'todo/write') return event.data.todos        if (event.type === 'turn/start') return null        return state      },

这段是投影的状态转移。注意看三个分支:todo/write 直接取事件里的整表,last-wins;turn/start 清回 null;其余事件原样返回 state。

turn/end 不在清单里,故意不清(注册处注释 index.ts:130-134)。回合结束的瞬间,完成的清单在视图里仍然可见,用户点开会话看到的是这回合做完了什么。下个回合 turn/start 一到,清空重开。计划属于回合内,成果展示属于回合末。

主线案例跨回合时撞上这条。修 lint 的清单做完,用户接着说「把告警也处理了」。turn/start 把旧表清成 null,模型在新回合重写一张新表。跨回合的长任务因此每回合都要重写全表,standing-plan 语义的代价,第七节一起算。

呈现侧还有两个细节。投影注册包在可选注入的子上下文里(index.ts:135),无头组装不装投影缝时整段跳过,工具照常工作,只是没人折清单。调用瞬间的卡片由 presentCall 给(index.ts:224),rawInput 展示模型提交的表;完成态渲染由 UI 直接消费 todos 投影,UI 不解析工具结果文本反推清单。

七、这笔账

整表替换省下了什么?模型不再管理 id、版本、冲突,patch 构造的可靠性问题整个消失;快照幂等,日志里每条 todo/write 都是完整真相,回放不用重建。

花掉了什么?token 随任务数线性增长,30 项的清单改 1 项也重发 30 项,所以描述末尾要求 Skip the list for trivial single-step tasks(index.ts:64);跨回合长任务每回合重写全表;同表去重按 content 精确匹配,「修复 X」和「修复 x」是两个字符串,各自合法,模型本想改同一条待办、只换了大小写,新表里就躺着两条几乎一样的待办,没有任何报错。

结语

回到主线案例的收尾。复跑验证通过,模型第三次写表,三项全 completed;turn/end 不清,会话视图停在这张清单上。工具这边什么都没攒下:index.ts:213 那行 append 跑完,包里的状态还是零。3 项还是 30 项,append 这行代码一个字都不用改。

todo_write 是单 agent 的自我规划,allowParallelInProgress 注释里列的第一类场景却是 subagents。子代理一跑起来,多条 in_progress 才名正言顺,父代理的日志树也跟着长出新枝。下一篇拆 subagent 插件,看任务怎么分发下去,子会话怎么挂回父会话。

你的 Agent 待办清单是增量的还是整表的,模型用得稳吗?评论区聊聊。


本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。

相关学习资料