教程整体目标
通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。
任务与状态
把一句自然语言请求转换成可以持续推进的任务状态。
预计 30 分钟本页目录
最小 Agent 已经能够搜索、修改并验证一个仓库任务。
src/types.tssrc/state.tssrc/index.tstests/state.test.ts
Step 1定义消息与步骤
扩展 src/types.ts。前面最小 Loop 的内存计数只证明控制流可以工作;现在把聊天消息、Harness 步骤和审计事件分开保存,为权限等待、用户输入和崩溃恢复建立稳定状态。
export interface AgentMessage { role: "user" | "assistant"; content: string;}export interface AgentStep { number: number; kind: "model" | "tool"; summary: string;}export interface AgentEvent { eventId: string; sequence: number; recordedAt: string; type: string; detail: string;}export interface AgentBudget { maxSteps: number; maxToolCalls: number; modelSteps: number; toolCalls: number;}Step 2定义任务状态
继续在 src/types.ts 添加:
import type { ContextSource } from "./context.js";export interface AgentState { runId: string; task: string; cwd: string; status: "running" | "waiting" | "completed" | "failed" | "blocked" | "cancelled"; messages: AgentMessage[]; contextSources: ContextSource[]; steps: AgentStep[]; changedFiles: string[]; changedFileHashes: Record<string, string>; mutationRevision: number; validations: ValidationRecord[]; requiredCriterionIds: string[]; failedAttempts: string[]; budget: AgentBudget; events: AgentEvent[]; nextEventSequence: number; stopReason: string | undefined;}export interface AgentResult { status: AgentState["status"]; answer: string; stopReason: StopReason; state: AgentState;}这个 AgentResult 替换最小 Loop 中绑定 MinimalAgentState 的版本;随后删除 MinimalAgentState。否则状态已经升级,返回类型却仍要求旧状态,Harness 会立刻类型错误。
Step 3从 CLI 任务初始化状态
创建 src/state.ts:
import { randomUUID } from "node:crypto";import type { AgentState } from "./types.js";export function createInitialState( task: string, cwd: string, limits = { maxSteps: 12, maxToolCalls: 24 },): AgentState { return { runId: randomUUID(), task, cwd, status: "running", messages: [{ role: "user", content: task }], contextSources: [], steps: [], changedFiles: [], changedFileHashes: {}, mutationRevision: 0, validations: [], requiredCriterionIds: [], failedAttempts: [], budget: { ...limits, modelSteps: 0, toolCalls: 0, }, events: [ { eventId: randomUUID(), sequence: 1, recordedAt: new Date().toISOString(), type: "run_started", detail: task, }, ], nextEventSequence: 2, stopReason: undefined, };}用 createInitialState 替换最小 Loop 中临时构造的 MinimalAgentState,并把验证章的 RunLedger 字段合并进来;这是迁移,不是重新创建一份账本。Harness 的 maxSteps、工具章加入的调用上限和当前计数都写入 budget,恢复运行时不能清零。先打印 runId,后续日志都使用它关联同一次运行。
Step 4用状态机执行合法转换
不要在各处分散写 state.status = ...。先创建通用事件追加函数,后续批准、用户输入、规划和恢复章节都复用它;事件 type 使用受测试约束的稳定字符串注册表,而不是每章重新声明一个不兼容的联合类型。
const allowedTransitions: Record<AgentState["status"], AgentState["status"][]> = { running: ["waiting", "completed", "failed", "blocked", "cancelled"], waiting: ["running", "failed", "cancelled"], blocked: [], completed: [], failed: [], cancelled: [],};export function transitionState( state: AgentState, next: AgentState["status"], reason: string, now = new Date(),): AgentState { if (!allowedTransitions[state.status].includes(next)) { throw new Error(`invalid state transition: ${state.status} -> ${next}`); } if (next !== "running" && !reason.trim()) { throw new Error("state transition requires a reason"); } return appendEvent( { ...state, status: next, stopReason: next === "running" ? undefined : reason, }, "status_changed", `${state.status} -> ${next}: ${reason}`, now, );}export function appendEvent( state: AgentState, type: string, detail: string, now = new Date(),): AgentState { return { ...state, events: [ ...state.events, { eventId: randomUUID(), sequence: state.nextEventSequence, recordedAt: now.toISOString(), type, detail, }, ], nextEventSequence: state.nextEventSequence + 1, };}本章的状态机只管理运行生命周期;前一章已经建立的验证证据门禁继续读取同一份状态,后面的规划章会再把 TaskPlan 嵌入其中。计划步骤状态不能反向覆盖运行状态。waiting 表示可由批准或用户回答恢复,blocked 表示策略或环境已确定拒绝、不能直接恢复,两者不要混用。
Step 5测试成功、失败和阻塞路径
创建 tests/state.test.ts,固定时间并验证:running → completed、running → failed、running → waiting → running、running → cancelled 与 running → blocked;终态不能恢复;空停止原因被拒绝;每次转换的 sequence 严格递增且 recordedAt 可解析。测试的是公开状态转换,不是 UUID 实现。
it("records a waiting and resumed run without losing audit order", () => { const initial = createInitialState("检查 package.json", "/workspace"); const waiting = transitionState(initial, "waiting", "approval_required", fixedTime); const resumed = transitionState(waiting, "running", "approval_granted", laterTime); expect(resumed.events.map((event) => event.sequence)).toEqual([1, 2, 3]); expect(resumed.status).toBe("running"); expect(resumed.stopReason).toBeUndefined();});