Skip to content

第三步 · 会话日志与 Agent 循环

目标:实现追加式会话日志与 turn/step 状态机——mini-harness 的心脏。对应文件:final-project/packages/mini-harness/src/agent/session.tsagent/agent.ts。对照原理篇 05/07源码拆解 03/04

3.1 会话日志(agent/session.ts,约 70 行)

ts
export class Session {
  private log: SessionEvent[] = []
  append<T>(type: string, data: T): SessionEvent<T> { /* 追加,只增不改 */ }
  deriveMessages(): Message[] { /* 从日志投影模型历史 */ }
  fork(childId: string, boundary?: number): Session { /* 截前缀生成子会话 */ }
}

deriveMessages 是"模型可见即已记录"的实现:

ts
switch (event.type) {
  case 'user/message':
  case 'assistant/message':
    messages.push(event.data.message)
    break
  case 'tool/result':
    // 工具结果以 user-role 消息进入模型历史(与真实协议一致)
    messages.push({
      role: 'user',
      content: [{ type: 'tool-result', toolCallId: …, content: …, isError: … }],
    })
    break
  default:
    break   // turn/step 边界事件不进入模型视野
}

投影是纯函数:同一份日志任何时刻投影结果相同。模型历史、UI 回放、审计三者的数据源是同一份事实。

3.2 Agent 的状态机(agent/agent.ts,约 300 行)

runTurn 的骨架与原理篇 5.2 时序图逐段对应:

ts
private async runTurn(message: Message): Promise<void> {
  this.turn += 1
  this.session.append('turn/start', { turn })       // ① 边界先落盘

  let pending: Message[] | null = [message]         // null = turn 结束
  try {
    while (pending !== null) {
      step += 1
      // ② agent/pre-step 瀑布:reject 或 enter(替换消息)
      const decision = await this.ctx.waterfall('agent/pre-step', this, pending, turn, step,
        async () => ({ kind: 'enter', messages: pending! }))
      if (decision.kind === 'reject') { …break… }

      this.session.append('step/start', { turn, step })   // ③
      for (const m of decision.messages) {
        this.session.append('user/message', { message: m })
      }
      const stepEnd = await this.runStep(turn, step)      // ④ 一次请求 + 工具
      this.session.append('step/end', { turn, step })

      if (stepEnd === 'tool-loop') {
        pending = []            // 工具结果已在日志里,下一步无需新用户消息
        continue
      }
      break
    }
    await this.ctx.serial('agent/turn-stopping', this, turn)  // ⑤ 最后检查点
  } catch (err) {
    // 取消 / 错误 → turnEndReason
  } finally {
    this.session.append('turn/end', { turn, reason })  // ⑥ 无论如何闭环
  }
}

三个实现决策

pending: Message[] | null 表达"是否还有下一步"。 工具循环的下一步没有新用户消息(结果已在日志),所以用 [] 而不是 null 表示"继续"。初版的 while (pending.length > 0) 在这里有个真实 bug:工具循环后 pending 被清空,turn 提前结束——状态机的"空"与"无"是两种语义,类型要能区分

finally 保证日志闭环。 turn 无论正常、取消还是抛错,turn/end 一定落盘,reason 区分 completed / aborted / error: …。崩溃恢复、审计、UI 收尾全部依赖这个不变量。

③ 取消 = abort 当前 turn 的 controller。 runStep 里每个 await 之后都有 this.abort.signal.throwIfAborted()——取消在最近的检查点生效,而不是等操作自然结束。

3.3 runStep:一次请求 + 工具执行

ts
private async runStep(turn: number, step: number): Promise<'done' | 'tool-loop' | 'max-tokens'> {
  // agent/request 瀑布:请求配置可被监听器替换
  const config = await this.ctx.waterfall('agent/request', this, turn, step, async () => defaultConfig)

  // 提示词装配 + request/header 落盘
  // llm.stream(历史从日志投影:模型可见即已记录)
  const stream = llm.stream({ …config, messages: this.session.deriveMessages(), … })
  for await (const chunk of stream) {
    this.session.append('assistant/chunk', { turn, step, chunk })   // 每个增量落盘
    assembler.push(chunk)
  }
  this.session.append('assistant/message', { …, usage: assembler.usage })

  // 工具循环:逐个执行 → tool/call + tool/result 落盘 → 'tool-loop'
}

对比真实 dsh 的 step()源码拆解 4.5):结构一一对应,只是少了重试策略、并发调度与 BlockAssembler 的深冻结。

3.4 驱动与状态

ts
followup(message: Message): Promise<void> {
  this.inbox.push(message)
  return this.wake()
}

wake() 是互斥的:running 时新消息只入队,当前 turn 结束后继续消费。agent/status 在 idle ⇄ running 间切换时 emit——前端的"取消"按钮与后端的会话列表都靠它。

小练习:给 turn/end 加 reason 统计

examples/hello-agent.ts 的 observer 里监听 session/eventturn/end 事件,统计每种 reason 的次数(completed / aborted / error)。再试着手动 agent.cancel('测试取消') 制造一个 aborted,验证 finally 闭环是否依然成立。

基于 DeepSeek Harness(开发者预览版 0.1.0-rc.6)与 Cordis 撰写