LinOnward / Agent TutorialAgent Tutorial
章节9 / 18
教程整体目标

通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。

08

任务与状态

把一句自然语言请求转换成可以持续推进的任务状态。

本页目录
本章任务运行“检查 package.json”,并分别验证任务完成、失败与阻塞的状态路径。
章节目标把已经能读写和验证仓库的最小 Agent,升级成具有合法转换、事件序号和停止原因的可审计任务运行。
开始之前

最小 Agent 已经能够搜索、修改并验证一个仓库任务。

本章涉及文件
  • src/types.ts
  • src/state.ts
  • src/index.ts
  • tests/state.test.ts

Step 1定义消息与步骤

扩展 src/types.ts。前面最小 Loop 的内存计数只证明控制流可以工作;现在把聊天消息、Harness 步骤和审计事件分开保存,为权限等待、用户输入和崩溃恢复建立稳定状态。

typescript
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 添加:

typescript
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

typescript
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 使用受测试约束的稳定字符串注册表,而不是每章重新声明一个不兼容的联合类型。

typescript
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 → completedrunning → failedrunning → waiting → runningrunning → cancelledrunning → blocked;终态不能恢复;空停止原因被拒绝;每次转换的 sequence 严格递增且 recordedAt 可解析。测试的是公开状态转换,不是 UUID 实现。

typescript
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();});