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

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

10

任务分解与规划

把用户目标拆成可执行步骤,用证据推进,并在计划失效时重规划。

本页目录
本章任务为“给示例 CLI 增加 --name 参数并补充测试”生成并校验 TaskPlan。
章节目标把用户目标转换成可审计、可执行、可修订的 TaskPlan,并让 Agent Loop 每轮只推进一个明确步骤。
开始之前

最小 Agent 已经能够调查、修改、验证并在权限策略下执行仓库任务。

本章涉及文件
  • src/plan.ts
  • src/planner.ts
  • src/types.ts
  • src/agent-loop.ts
  • tests/plan.test.ts

一个能调用工具的循环还不等于能完成任务的 Agent。面对“修复登录失败并验证”这样的目标,它必须先明确完成标准,拆出有依赖关系的步骤,再用工具返回的证据推进计划。规划不是隐藏思维过程,而是 Harness 保存的一份可观察工作清单。

实验一:生成与校验计划

Step 1区分 Goal、Plan 与 Step

先固定三个概念,避免模型每一轮都重新解释任务:

  1. Goal:用户要求和约束,是本次运行的稳定契约,重规划不能偷偷改写它;
  2. Plan:为了实现 Goal 而提出的当前执行假设,可以随新证据修订;
  3. Step:一次可验证的工作单元,必须说明完成它需要什么证据。

例如,用户说“修复登录失败并运行测试”:Goal 保留原句;Plan 可以是“复现 → 定位 → 修改 → 验证”;当前 Step 则是“运行登录测试并保存失败输出”。不要让模型输出冗长推理,只要求简短步骤、依赖关系和可观察完成条件。

Step 2定义任务与计划状态

src/types.ts 中加入计划领域类型,并把 Plan 放进 AgentState。状态由 Harness 修改,模型只能提出候选计划。

typescript
export interface AcceptanceCriterion {  id: string;  description: string;  status: "unverified" | "passed" | "failed";  evidence?: string;}export interface PlanStep {  id: string;  title: string;  status: "pending" | "in_progress" | "completed" | "blocked";  dependsOn: string[];  completionEvidence: string;  evidence: string[];}export interface TaskPlan {  version: number;  goal: string;  acceptanceCriteria: AcceptanceCriterion[];  steps: PlanStep[];  revisionReason?: string;}export interface AgentState {  // 保留上一章的 runId、task、messages、contextSources 等字段  plan?: TaskPlan | undefined;  activeStepId?: string | undefined;  planHistory: Array<{    version: number;    reason: string;    changedAt: string;  }>;}export interface ContextKindMap {  task_plan: true;}

AgentState 扩展放在 src/types.ts,并在 createInitialState 中加入 plan: undefinedactiveStepId: undefinedplanHistory: [];把 task_plan 加在 src/context.tsContextKindMapacceptanceCriteria 回答“整个任务何时完成”,completionEvidence 回答“这个步骤何时完成”。两者不能互相替代:代码写完可能完成了一个 Step,但测试没有通过时 Goal 仍未完成。

Step 3生成并校验初始计划

创建 src/planner.ts。Planner 是模型边界,返回结构化候选;validatePlan 是确定性边界,拒绝空计划、重复 ID、未知依赖和循环依赖。

typescript
export interface PlanDraft {  acceptanceCriteria: Array<{ id: string; description: string }>;  steps: Array<{    id: string;    title: string;    dependsOn: string[];    completionEvidence: string;  }>;}export interface Planner {  create(input: {    goal: string;    context: string;    availableTools: string[];  }): Promise<PlanDraft>;  revise(input: {    current: TaskPlan;    reason: ReplanReason;    observations: string[];  }): Promise<PlanDraft>;  evaluate(input: {    step: PlanStep;    observations: string[];  }): Promise<{    completed: boolean;    evidence: string[];    passedCriteria: string[];    replanReason?: ReplanReason;  }>;}function isRecord(value: unknown): value is Record<string, unknown> {  return typeof value === "object" && value !== null && !Array.isArray(value);}function isAcceptanceCriterion(  value: unknown,): value is PlanDraft["acceptanceCriteria"][number] {  return (    isRecord(value) &&    typeof value.id === "string" &&    value.id.length > 0 &&    typeof value.description === "string" &&    value.description.length > 0  );}function isDraftStep(value: unknown): value is PlanDraft["steps"][number] {  return (    isRecord(value) &&    typeof value.id === "string" &&    value.id.length > 0 &&    typeof value.title === "string" &&    value.title.length > 0 &&    Array.isArray(value.dependsOn) &&    value.dependsOn.every((dependency) => typeof dependency === "string") &&    typeof value.completionEvidence === "string" &&    value.completionEvidence.length > 0  );}export function validatePlan(candidate: unknown): asserts candidate is PlanDraft {  if (!isRecord(candidate)) throw new Error("plan must be an object");  const { acceptanceCriteria, steps } = candidate;  if (!Array.isArray(acceptanceCriteria) || acceptanceCriteria.length === 0) {    throw new Error("plan requires at least one acceptance criterion");  }  if (!Array.isArray(steps) || steps.length === 0) {    throw new Error("plan requires at least one step");  }  if (!acceptanceCriteria.every(isAcceptanceCriterion)) {    throw new Error("invalid acceptance criterion");  }  if (!steps.every(isDraftStep)) throw new Error("invalid plan step");  const criterionIds = acceptanceCriteria.map((criterion) => criterion.id);  if (new Set(criterionIds).size !== criterionIds.length) {    throw new Error("duplicate acceptance criterion id");  }  const ids = steps.map((step) => step.id);  if (new Set(ids).size !== ids.length) throw new Error("duplicate step id");  const knownIds = new Set(ids);  for (const step of steps) {    if (step.dependsOn.some((dependency) => !knownIds.has(dependency))) {      throw new Error(`unknown dependency in step: ${step.id}`);    }  }  const visiting = new Set<string>();  const visited = new Set<string>();  const byId = new Map(steps.map((step) => [step.id, step]));  const visit = (id: string): void => {    if (visiting.has(id)) throw new Error("plan contains a dependency cycle");    if (visited.has(id)) return;    visiting.add(id);    for (const dependency of byId.get(id)?.dependsOn ?? []) visit(dependency);    visiting.delete(id);    visited.add(id);  };  for (const id of ids) visit(id);}export async function createInitialPlan(  goal: string,  context: string,  tools: string[],  planner: Pick<Planner, "create">,): Promise<TaskPlan> {  const draft = await planner.create({ goal, context, availableTools: tools });  validatePlan(draft);  return {    version: 1,    goal,    acceptanceCriteria: draft.acceptanceCriteria.map((criterion) => ({      ...criterion,      status: "unverified",    })),    steps: draft.steps.map((step) => ({      ...step,      status: "pending",      evidence: [],    })),  };}export function createModelPlanCreator(model: Model): Pick<Planner, "create"> {  return {    async create(input) {      const raw = await model.generate({        instructions: [          "You create short, executable plans for repository tasks.",          "Return one JSON object and no Markdown fences or commentary.",          "Use exactly this shape:",          '{"acceptanceCriteria":[{"id":"...","description":"..."}],',          '"steps":[{"id":"...","title":"...","dependsOn":[],',          '"completionEvidence":"..."}]}',          "Every step must be possible with the declared tools.",          "Every completionEvidence must describe observable evidence.",        ].join("\n"),        input: [{ role: "user", content: JSON.stringify(input) }],      });      let candidate: unknown;      try {        candidate = JSON.parse(raw);      } catch {        throw new Error("planner returned invalid JSON");      }      validatePlan(candidate);      return candidate;    },  };}

初始计划写入状态时,同步设置 state.requiredCriterionIds = plan.acceptanceCriteria.map(({ id }) => id);验证规格只能引用这个集合中的 ID。重规划若改变验收条件,也要同时更新该字段并使不再匹配的旧验证证据失效,避免规划章和验证章各维护一套完成标准。

要求 Planner 只使用已声明的工具能力,不要编造“查询数据库”之类无法执行的步骤。模型可通过 Responses API 返回结构化文本或调用自定义工具,但 API 只负责产生响应;候选计划的持久化、校验和状态转换仍属于应用代码。OpenAI Responses API

实验二:推进、证据与重规划

Step 4用确定性规则推进计划

创建 src/plan.ts。选择下一步不需要再问模型:只取依赖全部完成的第一个 pending Step,并保证同一时刻最多一个 in_progress

typescript
function updateStep(  plan: TaskPlan,  stepId: string,  update: (step: PlanStep) => PlanStep,): TaskPlan {  const step = plan.steps.find((candidate) => candidate.id === stepId);  if (!step) throw new Error(`unknown plan step: ${stepId}`);  return {    ...plan,    steps: plan.steps.map((candidate) =>      candidate.id === stepId ? update(candidate) : candidate,    ),  };}export function selectNextStep(plan: TaskPlan): PlanStep | undefined {  const completed = new Set(    plan.steps.filter((step) => step.status === "completed").map((step) => step.id),  );  return plan.steps.find(    (step) =>      step.status === "pending" &&      step.dependsOn.every((dependency) => completed.has(dependency)),  );}export function startStep(plan: TaskPlan, stepId: string): TaskPlan {  if (plan.steps.some((step) => step.status === "in_progress")) {    throw new Error("another plan step is already in progress");  }  const selected = selectNextStep(plan);  if (selected?.id !== stepId) throw new Error("step is not ready");  return updateStep(plan, stepId, (step) => ({ ...step, status: "in_progress" }));}export function completeStep(  plan: TaskPlan,  stepId: string,  evidence: string,): TaskPlan {  if (!evidence.trim()) throw new Error("completion evidence is required");  return updateStep(plan, stepId, (step) => {    if (step.status !== "in_progress") throw new Error("step is not in progress");    return { ...step, status: "completed", evidence: [...step.evidence, evidence] };  });}export function allCriteriaPassed(plan: TaskPlan): boolean {  return plan.acceptanceCriteria.every((criterion) => criterion.status === "passed");}

工具成功不一定等于 Step 完成。例如 read_file 成功只证明文件已读取;是否已经“定位故障原因”还要由一个明确的步骤结果归约器判断,并把所依据的 observation 摘要保存为 evidence。

本教程通过 Planner.evaluate 实现这个归约边界:它只能引用本轮已有 observation,Harness 再校验引用、更新 Step 和验收条件。这样模型可以判断语义是否满足,但不能绕过证据直接修改状态。

Step 5只在计划失效时重规划

每轮都重新生成计划会抖动、浪费 token,还可能遗忘已完成工作。先用确定性信号决定是否需要重规划:

typescript
export type ReplanReason =  | "new_constraint"  | "failed_assumption"  | "repeated_tool_failure"  | "no_ready_step";export function shouldReplan(input: {  userChangedGoal: boolean;  failedAssumption: boolean;  consecutiveToolFailures: number;  plan: TaskPlan;}): ReplanReason | undefined {  if (input.userChangedGoal) return "new_constraint";  if (input.failedAssumption) return "failed_assumption";  if (input.consecutiveToolFailures >= 2) return "repeated_tool_failure";  const unfinished = input.plan.steps.some(    (step) => step.status === "pending" || step.status === "in_progress",  );  const active = input.plan.steps.some((step) => step.status === "in_progress");  if (unfinished && !active && !selectNextStep(input.plan)) return "no_ready_step";  return undefined;}export function reconcilePlan(  current: TaskPlan,  draft: PlanDraft,  reason: ReplanReason,): TaskPlan {  validatePlan(draft);  const completedSteps = new Map(    current.steps      .filter((step) => step.status === "completed")      .map((step): [string, PlanStep] => [step.id, step]),  );  const arraysEqual = (left: string[], right: string[]): boolean =>    left.length === right.length && left.every((value, index) => value === right[index]);  const sameStepContract = (previous: PlanStep, next: PlanDraft["steps"][number]): boolean =>    previous.title === next.title &&    previous.completionEvidence === next.completionEvidence &&    arraysEqual(previous.dependsOn, next.dependsOn);  const sameCriterionContract = (    previous: AcceptanceCriterion,    next: PlanDraft["acceptanceCriteria"][number],  ): boolean => previous.description === next.description;  return {    version: current.version + 1,    goal: current.goal,    revisionReason: reason,    acceptanceCriteria: draft.acceptanceCriteria.map((criterion): AcceptanceCriterion => {      const previous = current.acceptanceCriteria.find(        (candidate) => candidate.id === criterion.id,      );      const canReuse = previous && sameCriterionContract(previous, criterion);      return {        ...criterion,        status: canReuse ? previous.status : "unverified",        ...(canReuse && previous.evidence ? { evidence: previous.evidence } : {}),      };    }),    steps: draft.steps.map((step): PlanStep => {      const completed = completedSteps.get(step.id);      return completed && sameStepContract(completed, step)        ? completed        : { ...step, status: "pending", evidence: [] };    }),  };}

Planner 返回修订草案后,reconcilePlan 保持 Goal 原文,但只在 ID 和语义契约都不变时继承状态:Step 要同时匹配标题、依赖与完成证据;验收条件要匹配描述。只复用 ID 会让已经改变含义的新步骤错误继承 completed/passed。Harness 随后把变更原因写入 planHistory 并发出 plan_revised 事件。新计划仍要通过与初始计划相同的结构和依赖校验。

Step 6把 Plan 接入 Agent Loop

Plan 位于 Assemble 和 Decide 之间:Loop 先选择 ready Step,再把 Goal、完成标准、计划摘要、当前 Step 与最近 observation 一起交给模型。工具结果返回后,Harness 更新 Step,而不是让模型直接覆盖整个 Plan。

text
INITIALIZE task stateCREATE and validate initial planWHILE run is active  REPLAN only when shouldReplan(...) returns a reason  SELECT the next dependency-ready step  ASSEMBLE goal + acceptance criteria + plan summary + active step + context  DECIDE one model turn  IF tool calls exist    ACT and collect observations    REDUCE observations into step progress    COMPLETE the step only when its required evidence exists    CONTINUE the loop  IF final text exists    COMPLETE only when every step is completed and allCriteriaPassed(plan) is true    OTHERWISE continue with the first unverified criterion  STOP on cancellation, budget exhaustion, invalid output, or unrecoverable block

模型请求中的计划摘要可以采用稳定格式:[✓] completed[→] in progress[ ] pending[!] blocked。不要把所有历史版本都塞回上下文;当前版本进入 Prompt,旧版本留在审计日志中。

src/plan.ts 导出 Context Builder 后面会直接使用的适配器,不要等到完整 Loop 章再调用一个未定义函数:

typescript
export function planAsContextSource(  plan: TaskPlan,  activeStep: PlanStep | undefined,): ContextSource {  return {    id: `plan:${plan.version}`,    kind: "task_plan",    label: activeStep ? `active step: ${activeStep.id}` : "current plan",    content: JSON.stringify({      goal: plan.goal,      acceptanceCriteria: plan.acceptanceCriteria,      steps: plan.steps,      activeStepId: activeStep?.id,    }),    priority: 99,  };}

同一文件还要导出完整 Loop 使用的证据归并函数。示例先采用严格的“证据必须等于本轮 observation”规则,防止模型伪造来源;后续可升级成 observation ID:

typescript
export function assertEvidenceComesFromObservations(  evidence: string[],  observations: string[],): void {  if (evidence.some((item) => !observations.includes(item))) {    throw new Error("planner returned evidence outside current observations");  }}export function applyCriterionEvidence(  plan: TaskPlan,  passedCriterionIds: string[],  observations: string[],): TaskPlan {  const passed = new Set(passedCriterionIds);  for (const id of passed) {    if (!plan.acceptanceCriteria.some((criterion) => criterion.id === id)) {      throw new Error(`unknown acceptance criterion: ${id}`);    }  }  return {    ...plan,    acceptanceCriteria: plan.acceptanceCriteria.map((criterion) =>      passed.has(criterion.id)        ? { ...criterion, status: "passed", evidence: observations.join("\n") }        : criterion,    ),  };}
实验三:状态机测试与真实任务

Step 7测试计划状态机,而不是计划文案

创建 tests/plan.test.ts,用固定 PlanDraft 覆盖以下行为:

  1. 未完成依赖的 Step 不能被选择或启动;
  2. 同一时刻不能有两个 in_progress Step;
  3. 没有 evidence 不能完成 Step;
  4. 工具连续失败两次触发 repeated_tool_failure
  5. 重规划保留 Goal、已完成 Step 和 evidence,并递增版本;
  6. 仍有未验证的 acceptance criterion 时,模型文本不能让 Loop 提前完成。
  7. Step 的标题、依赖或完成证据变化时,旧 completed 状态失效;
  8. 验收描述变化时,旧 passed 状态失效;
  9. 重复的 acceptance criterion ID 被拒绝。
typescript
import { describe, expect, it } from "vitest";import {  allCriteriaPassed,  completeStep,  reconcilePlan,  selectNextStep,  shouldReplan,  startStep,} from "../src/plan.js";import { validatePlan } from "../src/planner.js";import type { AcceptanceCriterion, PlanStep, TaskPlan } from "../src/types.js";function createPlanFixture(): TaskPlan {  return {    version: 1,    goal: "修复登录失败并运行测试",    acceptanceCriteria: [      {        id: "login-test-passes",        description: "登录测试通过",        status: "unverified",      },    ],    steps: [      {        id: "reproduce",        title: "复现登录失败",        status: "pending",        dependsOn: [],        completionEvidence: "保存失败测试输出",        evidence: [],      },      {        id: "diagnose",        title: "定位失败原因",        status: "pending",        dependsOn: ["reproduce"],        completionEvidence: "记录根因和相关代码位置",        evidence: [],      },    ],  };}describe("plan state machine", () => {  it("rejects a step whose dependency is unfinished", () => {    const plan = createPlanFixture();    expect(selectNextStep(plan)?.id).toBe("reproduce");    expect(() => startStep(plan, "diagnose")).toThrow("step is not ready");  });  it("prevents two steps from running at once", () => {    const running = startStep(createPlanFixture(), "reproduce");    expect(() => startStep(running, "diagnose")).toThrow(      "another plan step is already in progress",    );  });  it("requires evidence before completing a step", () => {    const running = startStep(createPlanFixture(), "reproduce");    expect(() => completeStep(running, "reproduce", "   ")).toThrow(      "completion evidence is required",    );  });  it("replans after repeated tool failures", () => {    expect(      shouldReplan({        userChangedGoal: false,        failedAssumption: false,        consecutiveToolFailures: 2,        plan: createPlanFixture(),      }),    ).toBe("repeated_tool_failure");  });  it("preserves completed work when replanning", () => {    const running = startStep(createPlanFixture(), "reproduce");    const completed = completeStep(      running,      "reproduce",      "login.test.ts fails with 401",    );    const revised = reconcilePlan(      completed,      {        acceptanceCriteria: [          { id: "login-test-passes", description: "登录测试通过" },        ],        steps: [          {            id: "reproduce",            title: "复现登录失败",            dependsOn: [],            completionEvidence: "保存失败测试输出",          },          {            id: "diagnose",            title: "检查认证服务返回值",            dependsOn: ["reproduce"],            completionEvidence: "记录根因和相关代码位置",          },        ],      },      "failed_assumption",    );    expect(revised.version).toBe(2);    expect(revised.goal).toBe(completed.goal);    expect(revised.steps[0]).toMatchObject({      id: "reproduce",      status: "completed",      evidence: ["login.test.ts fails with 401"],    });  });  it("invalidates completed work when the step contract changes", () => {    const running = startStep(createPlanFixture(), "reproduce");    const completed = completeStep(running, "reproduce", "saved failure output");    const revised = reconcilePlan(      completed,      {        acceptanceCriteria: [{ id: "login-test-passes", description: "登录测试通过" }],        steps: [          {            id: "reproduce",            title: "复现并定位登录失败",            dependsOn: [],            completionEvidence: "保存失败测试输出与根因",          },        ],      },      "failed_assumption",    );    expect(revised.steps[0]).toMatchObject({ status: "pending", evidence: [] });  });  it("invalidates passed criteria when their description changes", () => {    const current = createPlanFixture();    const criterion = current.acceptanceCriteria[0];    if (!criterion) throw new Error("fixture criterion missing");    current.acceptanceCriteria[0] = {      ...criterion,      status: "passed",      evidence: "old evidence",    };    const revised = reconcilePlan(      current,      {        acceptanceCriteria: [          { id: "login-test-passes", description: "登录和登出测试都通过" },        ],        steps: current.steps,      },      "new_constraint",    );    expect(revised.acceptanceCriteria[0]).toEqual({      id: "login-test-passes",      description: "登录和登出测试都通过",      status: "unverified",    });  });  it("rejects duplicate acceptance criterion ids", () => {    expect(() =>      validatePlan({        acceptanceCriteria: [          { id: "same", description: "first" },          { id: "same", description: "second" },        ],        steps: [          { id: "inspect", title: "Inspect", dependsOn: [], completionEvidence: "output" },        ],      }),    ).toThrow("duplicate acceptance criterion id");  });  it("rejects early completion while criteria remain unverified", () => {    const plan = createPlanFixture();    const unverified: TaskPlan = {      ...plan,      steps: plan.steps.map(        (step): PlanStep => ({ ...step, status: "completed" }),      ),    };    expect(allCriteriaPassed(unverified)).toBe(false);    const verified: TaskPlan = {      ...unverified,      acceptanceCriteria: unverified.acceptanceCriteria.map(        (criterion): AcceptanceCriterion => ({          ...criterion,          status: "passed",          evidence: "login.test.ts passed",        }),      ),    };    expect(allCriteriaPassed(verified)).toBe(true);  });});

这九个测试分别验证依赖调度、互斥执行、证据门禁、重规划触发、语义状态协调、重复 ID 和任务完成门禁。它们不调用真实模型或真实工具,因此结果稳定,也能在 Agent Loop 接入模型之前定位 Harness 的状态错误。

Step 8用贯穿教程的真实任务验收规划

单元测试通过后,再使用第 00 章写入 GOAL.md 的真实工程任务。暂时把规划入口接到 src/index.ts:默认使用固定离线计划验收装配;显式设置 MODEL_MODE=openai 时才调用真实模型。两条路径都只生成并打印计划,不修改文件或执行命令。

typescript
import { createModel } from "./model-factory.js";import { createInitialPlan, createModelPlanCreator } from "./planner.js";const goal = process.argv.slice(2).join(" ").trim();if (!goal) throw new Error("请提供要规划的任务");const offlineDraft = {  acceptanceCriteria: [    { id: "name-success", description: "--name Lin 输出 Lin" },    { id: "name-missing", description: "缺少参数值时清晰失败" },    { id: "cli-regression", description: "现有 CLI 行为保持通过" },  ],  steps: [    {      id: "inspect",      title: "读取 CLI、测试和 package.json",      dependsOn: [],      completionEvidence: "取得三个文件的 read_file observation",    },    {      id: "implement",      title: "修改 CLI 并补充成功与错误测试",      dependsOn: ["inspect"],      completionEvidence: "实现与测试文件产生受控补丁",    },    {      id: "validate",      title: "运行最窄相关测试",      dependsOn: ["implement"],      completionEvidence: "当前 revision 的 CLI 测试通过",    },  ],};const model = createModel([JSON.stringify(offlineDraft)]);const planner = createModelPlanCreator(model);const plan = await createInitialPlan(  goal,  "当前入口是 src/index.ts,现有测试是 tests/cli.test.ts。",  ["read_file", "apply_patch", "run_command"],  planner,);console.log(JSON.stringify({ goal, plan }, null, 2));

先执行可复现的离线规划;配置了 Key 时再选择执行一次真实规划:

shell
pnpm dev -- "给示例 CLI 增加 --name 参数并补充测试"MODEL_MODE=openai pnpm dev -- "给示例 CLI 增加 --name 参数并补充测试"

不要只检查“输出了 JSON”,要逐项验收输出内容:

  1. plan.goal 必须与命令中的任务原文完全一致;
  2. acceptance criteria 必须同时覆盖 --name Lin 的成功行为、缺少参数值的错误行为和现有 CLI 行为不回归;
  3. 第一个 Step 应先读取 src/index.tstests/cli.test.tspackage.json,不能直接假设实现;
  4. 修改与补测试的 Step 必须依赖调查 Step,验证 Step 必须依赖该实现 Step;
  5. 每个 completionEvidence 都必须能由文件差异或命令输出证明,不能写“看起来正确”;
  6. 输出必须是 version: 1,所有 Step 是 pending,所有验收条件是 unverified

如果模型生成未知依赖、循环依赖、重复 ID 或空完成证据,validatePlan 应让命令失败。修正 Planner Prompt 或解析边界并重新运行,不要手工美化输出后假装通过。