深色模式
第三步 · 会话日志与 Agent 循环
目标:实现追加式会话日志与 turn/step 状态机——mini-harness 的心脏。对应文件:
final-project/packages/mini-harness/src/agent/session.ts、agent/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/event 的 turn/end 事件,统计每种 reason 的次数(completed / aborted / error)。再试着手动 agent.cancel('测试取消') 制造一个 aborted,验证 finally 闭环是否依然成立。