Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 8 章:会写代码 — Coding Agent 闭环

一句话定位:你会让 agent 接到一段 issue 描述后,自己规划、改代码、跑测试、把结果版本化地推到 Artifacts 仓库 —— 整条链可重试、可恢复、可观察。

想要什么

到第 7 章为止,Think agent 已经会聊天、会记住、会调工具、能在 Sandbox 里跑命令、能把产物提交进自己的 Artifacts 仓库。但它依然是“问答型“——你说一句它做一件,做完等下一句。

我们要的是另一种东西。打开终端,扔一个 issue 进去:

# Terminal
curl -X POST http://localhost:8787/api/issues/42/solve \
  -H 'content-type: application/json' \
  -d '{
    "title": "README 第二段把 Worker 写成了 Wrokr",
    "body": "请改正 README.md 第 7 行的拼写错误,加测试。"
  }'

回车后立刻拿到一个 instanceId,屏幕静默几十秒到几分钟。再 curl 一次状态,看到 status: complete,代码改了、测试跑过了、推送到 Artifacts 仓库的 commit SHA 也回来了。

这就是从“问答型“升级到“任务型“:你给目标,它自己拆步骤、做事、验收、入库。中间所有过程都可恢复 —— 哪怕 worker 重启、Sandbox 被回收、LLM 请求超时,都能从最近的 step 边界继续。

图 8-1:CoderAgent 接 issue 的全景

CoderAgent 把 issue 转交给 SolveIssue Workflow,Workflow 调三个 Facets sub-agent 用户 curl / UI CoderAgent (Think) solve(issue) → runWorkflow issues 落 this.sql SolveIssue (AgentWorkflow v2) step.do plan / edit / verify commitToArtifact 收尾 PlannerAgent subAgent Facet EditorAgent subAgent Facet VerifierAgent subAgent Facet 用户拿到 instanceId 后,通过 onWorkflowProgress 实时拿进度

入口路由把 issue 落进 CoderAgent 的 this.sql,然后立刻把执行权交给 Workflow,长任务在 Worker 单次调用之外活着。三个 sub-agent 是 Facets(Project Think 的同进程子 DO),各自一份 SQLite,主 agent 只看结果。

为什么

如果只在 onChatMessageawait 一长串工具调用,会撞上三堵墙。

第一,时长。Workers 单次 invocation 走完 30 秒就该让位。一次“读 issue → 列文件 → 改代码 → npm installnpm test“随手就 5 分钟。中间任何一次驱逐,内存里的 promise 链全部蒸发。

第二,可观测性。Plan、edit、verify 三步混在同一个 async 函数里,失败时你只能看到一坨 stack trace。“只重跑 verify 这一步“这种需求根本无从下手。

第三,SQL 污染。CoderAgent 的 this.sql 已经在记会话消息、Session context、artifact 索引。再往里塞 plan step、patch diff、test log,几个会话之后表就乱了。

Workflows v2 解决前两个,Facets sub-agent 解决第三个,think 工具让推理过程可追溯。三件套合起来,就是一个能把“修个 typo“的 issue 走通的最小 coding agent —— 而且一开始就长在能扛 50000 并发实例的架构上。

方案选择

Workflow vs runFiber() + keepAlive()

两者都能扛 invocation 级驱逐,但适用边界不同。

维度runFiber() + keepAlive()Workflow(AgentWorkflow)
关注点单 agent 内部某段重活跨步骤、可独立失败的流水线
重试粒度整个 fiber 重进 onFiberRecovered每个 step.do() 独立重试 + 退避
单步上限DO 激活窗口 + 心跳每 step 30 分钟
可观测性自己写日志Workflow 控制台原生 step 树
人工审批自己拼 state + WebSocket内置 step.waitForApproval()
适合LLM 流式恢复、临时检查点plan / edit / verify 这种独立步骤

决策口诀:单步 > 30s、跨 invocation 持久、需要可观察 step 树 —— 三条占两条就用 Workflow,只想保 LLM 流式生成“别断“则用 runFiber

我们选 Workflow。plan、edit、verify 三步逻辑独立、失败模式也不同(plan 失败 = 重新让 LLM 想一次;edit 失败 = sandbox 写文件冲突;verify 失败 = 测试挂),需要分别重试。Fiber 留给“LLM 流式响应不能掉“这种 agent 内部的事 —— 第 4 章已经在用。

子任务隔离:Facets 子 agent

每个 step 我们派一个 sub-agent 跑:PlannerAgentEditorAgentVerifierAgent。它们都是 CoderAgentFacet(Project Think 在 Durable Object Facets 之上的封装,详见 Facets 公告),SQL 完全隔离、跑在同一个 DO 进程里、零额外网络跳数

  • PlannerAgent 自己存 plan 历史,失败重试不污染主会话
  • EditorAgent 自己存改过哪些文件、写了什么内容
  • VerifierAgent 自己存测试日志,跑了 3 遍可以全留着
  • CoderAgent 只拿到结果

Facets 与“再开一个 DO 命名空间“的区别:Facets 共享父 DO 的容器/进程,RPC 是同进程方法调用,没有网络往返;独立 DO 命名空间是真跨网络。三个 sub-agent 之间没有跨用户复用、也没有独立扩容需求 —— Facets 正合适。

图 8-2:三个 sub-agent 的分工

三个 Facet sub-agent 的分工:planner 出 plan,editor 写文件,verifier 跑测试 PlannerAgent 读 issue + repo tree generateObject 出 PlanStep[] SQL: plans 表 think 工具留推理痕迹 EditorAgent sandbox.writeFile 改文件 sandbox.exec git add SQL: edits 表 输出 changedFiles VerifierAgent sandbox.exec npm test 回 passed + summary SQL: runs 表 失败 → 把日志回喂 plan 主 CoderAgent 只看 plan 摘要 / changedFiles / passed,不持有中间状态

落地

整套改动分四块:think 工具三个 Facet sub-agentSolveIssue Workflow主 agent 接 issue + Workflow 回调,最后是 wrangler 增量

文件布局增量

src/
├── workflows/
│   └── solve-issue.ts       # AgentWorkflow,3 step + commit
└── sub-agents/
    ├── think-tool.ts        # 公用 think() 工具
    ├── planner.ts           # PlannerAgent extends Agent
    ├── editor.ts            # EditorAgent extends Agent
    └── verifier.ts          # VerifierAgent extends Agent

第 7 章已经写好的 commitToArtifact(在 src/tools/artifact.ts)和 getSandbox() 直接复用。

think 工具:让推理可追溯

让 sub-agent 在做关键决定前,先写一段“我为什么这样做“,落到 this.sql。它不是给 LLM 看的备忘,是给调试时的你后续 verifier 失败回喂时的 planner看的。

// src/sub-agents/think-tool.ts
import { tool } from "ai";
import { z } from "zod";
import type { Agent } from "agents";

export const makeThinkTool = (agent: Agent<any>) =>
  tool({
    description:
      "Record an explicit reasoning note before taking action. " +
      "Use one note per major decision. The note is persisted and " +
      "shown to future planning attempts when retries happen.",
    inputSchema: z.object({
      topic: z.string().describe("e.g. 'plan-step-1', 'edit-decision', 'why-failed'"),
      reasoning: z.string().describe("Free-form 1-3 sentence rationale"),
    }),
    execute: async ({ topic, reasoning }) => {
      agent.sql`
        INSERT INTO thinks (topic, reasoning, ts)
        VALUES (${topic}, ${reasoning}, ${Date.now()})
      `;
      return { ok: true };
    },
  });

每个 sub-agent 在 onStart 里建好 thinks 表,后面查“上一次失败前模型怎么想的“就一条 SQL 的事。

PlannerAgent(Facet)

读 issue + 沙箱里仓库的文件树,让 LLM 出一份结构化 plan(JSON 数组)。失败重试时把上一次 verify 的报错带进 prompt。

// src/sub-agents/planner.ts
import { Agent } from "agents";
import { generateObject } from "ai";
import { z } from "zod";
import { getSandbox } from "@cloudflare/sandbox";
import { createWorkersAI } from "workers-ai-provider";
import type { CoderSandbox } from "../sandbox";

// 用 Cloudflare.Env 跟主 agent 共用一份(wrangler types 自动生成)
type Env = Cloudflare.Env;

const PlanStep = z.object({
  id: z.string(),
  description: z.string(),
  files: z.array(z.string()).describe("Files this step expects to touch"),
});
export type PlanStep = z.infer<typeof PlanStep>;

export class PlannerAgent extends Agent<Env> {
  async onStart() {
    this.sql`CREATE TABLE IF NOT EXISTS plans (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      issue_id TEXT, plan_json TEXT, attempt INTEGER, ts INTEGER
    )`;
    this.sql`CREATE TABLE IF NOT EXISTS thinks (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      topic TEXT, reasoning TEXT, ts INTEGER
    )`;
  }

  async makePlan(input: {
    issue: { id: string; title: string; body: string };
    conversationId: string;
    previousAttemptError?: string;
    attempt: number;
  }): Promise<PlanStep[]> {
    // 拿仓库结构当 LLM 上下文
    const sandbox = getSandbox(this.env.Sandbox, input.conversationId);
    const tree = await sandbox.exec(
      "find /workspace/repo -maxdepth 3 -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | head -80"
    );

    const wai = createWorkersAI({ binding: this.env.AI });
    const result = await generateObject({
      model: wai("@cf/meta/llama-3.3-70b-instruct-fp8-fast"),
      schema: z.object({ plan: z.array(PlanStep) }),
      system:
        "You break a software issue into 1-5 ordered steps. " +
        "Each step lists the exact files it expects to touch. " +
        "Be conservative: prefer fewer steps over speculative ones.",
      prompt: [
        `Issue: ${input.issue.title}`,
        ``,
        input.issue.body,
        ``,
        `Repository files:`,
        tree.stdout,
        input.previousAttemptError
          ? `\nPrevious attempt failed with:\n${input.previousAttemptError}\n` +
            `Adjust the plan to address this.`
          : ``,
      ].join("\n"),
    });

    this.sql`
      INSERT INTO plans (issue_id, plan_json, attempt, ts)
      VALUES (${input.issue.id}, ${JSON.stringify(result.object.plan)},
              ${input.attempt}, ${Date.now()})
    `;
    return result.object.plan;
  }
}

generateObject 强制 LLM 按 schema 出结构化结果,比让它写 markdown 再正则解析省心十倍。第 2 章已经讲过怎么把 model 切成 env.AI.run("anthropic/claude-...") —— 这里给的是 Workers AI 默认值,production 切换 provider 改一行即可。

EditorAgent(Facet)

逐 plan step 让 LLM 决定要写什么内容,直接调 sandbox.writeFile。沙箱 API 在 REAL_API_v2 §B.3 已经有 writeFile(path, content, opts?)exec(cmd, opts?),不需要再在镜像里跑自建 HTTP server。

// src/sub-agents/editor.ts
import { Agent } from "agents";
import { generateText, stepCountIs } from "ai";
import { getSandbox } from "@cloudflare/sandbox";
import { createWorkersAI } from "workers-ai-provider";
import type { CoderSandbox } from "../sandbox";
import type { PlanStep } from "./planner";
import { makeThinkTool } from "./think-tool";

// 用 Cloudflare.Env 跟主 agent 共用一份(wrangler types 自动生成)
type Env = Cloudflare.Env;

export class EditorAgent extends Agent<Env> {
  async onStart() {
    this.sql`CREATE TABLE IF NOT EXISTS edits (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      issue_id TEXT, step_id TEXT, file TEXT, bytes INTEGER, ts INTEGER
    )`;
    this.sql`CREATE TABLE IF NOT EXISTS thinks (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      topic TEXT, reasoning TEXT, ts INTEGER
    )`;
  }

  async applyPlan(input: {
    issue: { id: string; title: string; body: string };
    plan: PlanStep[];
    conversationId: string;
  }): Promise<{ changedFiles: string[]; summary: string }> {
    const sandbox = getSandbox(this.env.Sandbox, input.conversationId);
    const wai = createWorkersAI({ binding: this.env.AI });
    const changed = new Set<string>();
    const think = makeThinkTool(this);

    for (const step of input.plan) {
      // 把这一步要碰的文件读出来,塞进 prompt
      const files: Record<string, string> = {};
      for (const f of step.files) {
        const r = await sandbox.exec(
          `test -f /workspace/repo/${f} && cat /workspace/repo/${f} || echo ''`
        );
        files[f] = r.stdout;
      }

      // 让 LLM 直接写新版本,不写 diff —— diff 在小模型上太脆
      const result = await generateText({
        model: wai("@cf/meta/llama-3.3-70b-instruct-fp8-fast"),
        tools: { think },
        stopWhen: stepCountIs(6),
        system:
          "Implement ONE plan step. For each affected file, output the COMPLETE new content " +
          "wrapped as <file path=\"...\">...</file>. Call think() once before writing, " +
          "explaining the change in one sentence.",
        prompt: [
          `Issue: ${input.issue.title}\n${input.issue.body}`,
          `Step ${step.id}: ${step.description}`,
          ...Object.entries(files).map(([p, c]) => `Current <file path="${p}">\n${c}\n</file>`),
        ].join("\n\n"),
      });

      // 解析 <file> 块,逐个写回沙箱
      for (const m of result.text.matchAll(/<file path="([^"]+)">([\s\S]*?)<\/file>/g)) {
        const [, path, content] = m;
        await sandbox.writeFile(`/workspace/repo/${path}`, content.trim() + "\n");
        await sandbox.exec(`cd /workspace/repo && git add ${path}`);
        this.sql`
          INSERT INTO edits (issue_id, step_id, file, bytes, ts)
          VALUES (${input.issue.id}, ${step.id}, ${path}, ${content.length}, ${Date.now()})
        `;
        changed.add(path);
      }
    }

    return {
      changedFiles: [...changed],
      summary: `Touched ${changed.size} file(s) across ${input.plan.length} step(s)`,
    };
  }
}

注意几个细节:

  • 完整文件替换 而不是 unified diff —— 在 8B 量级开源模型上 diff 的格式错误率高得吓人,一个错位的 @@ 行就让整步白费。完整内容简单粗暴但稳。
  • sandbox.writeFile@cloudflare/sandbox 的一等 API,自动处理父目录、UTF-8 编码,不需要走自建的 /files/write 路由。
  • git add 留在沙箱里跑 —— 我们在第 9 章才会真正 push 到 GitHub,这一章的“提交“目的地是 Artifacts(下面 commitToArtifact)。

VerifierAgent(Facet)

最简单。跑 npm test,把 stdout/stderr 截短返回。

// src/sub-agents/verifier.ts
import { Agent } from "agents";
import { getSandbox } from "@cloudflare/sandbox";
import type { CoderSandbox } from "../sandbox";

// 用 Cloudflare.Env 跟主 agent 共用
type Env = Cloudflare.Env;

export class VerifierAgent extends Agent<Env> {
  async onStart() {
    this.sql`CREATE TABLE IF NOT EXISTS runs (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      issue_id TEXT, attempt INTEGER, passed INTEGER, summary TEXT, ts INTEGER
    )`;
  }

  async runTests(input: {
    issue: { id: string };
    conversationId: string;
    attempt: number;
  }): Promise<{ passed: boolean; summary: string }> {
    const sandbox = getSandbox(this.env.Sandbox, input.conversationId);

    // 测试命令 5 分钟硬上限,沙箱级 timeout 由 ExecOptions 控制
    const r = await sandbox.exec("cd /workspace/repo && npm test --silent 2>&1", {
      timeout: 5 * 60 * 1000,
    });

    const tail = (s: string) => (s.length > 4000 ? s.slice(-4000) : s);
    const summary = tail(r.stdout);
    const passed = r.exitCode === 0;

    this.sql`
      INSERT INTO runs (issue_id, attempt, passed, summary, ts)
      VALUES (${input.issue.id}, ${input.attempt}, ${passed ? 1 : 0}, ${summary}, ${Date.now()})
    `;

    return { passed, summary };
  }
}

sandbox.exec 直接吐 { stdout, stderr, exitCode }(REAL_API_v2 §B.3),不用包 HTTP server。

SolveIssue Workflow

整条流水线在这里。Workflow v2 的 AgentWorkflow 让我们用 this.agent.subAgent(Cls, name) 直接拿到 Facet stub,跨 step 边界类型安全。

// src/workflows/solve-issue.ts
import { AgentWorkflow } from "agents/workflows";
import type { AgentWorkflowEvent, AgentWorkflowStep } from "agents/workflows";
import type { CoderAgent } from "../agent";
import { PlannerAgent, type PlanStep } from "../sub-agents/planner";
import { EditorAgent } from "../sub-agents/editor";
import { VerifierAgent } from "../sub-agents/verifier";

export type SolveIssueParams = {
  issue: { id: string; title: string; body: string };
  conversationId: string;
};

export type SolveIssueResult = {
  ok: boolean;
  attempts: number;
  changedFiles: string[];
  artifactCommit?: { sha: string; ref: string };
  testSummary: string;
};

const MAX_ATTEMPTS = 3;

export class SolveIssue extends AgentWorkflow<CoderAgent, SolveIssueParams> {
  async run(
    event: AgentWorkflowEvent<SolveIssueParams>,
    step: AgentWorkflowStep
  ): Promise<SolveIssueResult> {
    const { issue, conversationId } = event.payload;

    // Facets:每个 sub-agent 一个稳定 name,这样多次 attempt 共享 SQL 历史。
    // SubAgentStub 会过滤掉 Agent 基类方法,在某些 TS 配置下推不出子类自己的方法,
    // 所以 cast 回具体类拿到强类型 handle。
    const planner = (await this.agent.subAgent(
      PlannerAgent,
      `plan-${issue.id}`,
    )) as unknown as PlannerAgent;
    const editor = (await this.agent.subAgent(
      EditorAgent,
      `edit-${issue.id}`,
    )) as unknown as EditorAgent;
    const verifier = (await this.agent.subAgent(
      VerifierAgent,
      `verify-${issue.id}`,
    )) as unknown as VerifierAgent;

    // CoderAgent 上的 commitToArtifact 是第 7 章给的 RPC 方法,
    // 但 DurableObjectStub<CoderAgent> 在当前 d.ts 下对自定义方法识别不全,cast 一下
    const agent = this.agent as unknown as CoderAgent;

    let lastError: string | undefined;
    let plan: PlanStep[] = [];
    let changedFiles: string[] = [];
    let testSummary = "";

    for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
      // ───────── Step 1: plan ─────────
      plan = await step.do(
        `plan-${attempt}`,
        { retries: { limit: 2, delay: "5 seconds", backoff: "exponential" } },
        async () =>
          planner.makePlan({
            issue,
            conversationId,
            previousAttemptError: lastError,
            attempt,
          }),
      );
      // 进度推回主 agent —— AgentWorkflow.reportProgress 会触发 onWorkflowProgress(name, id, progress)
      await this.reportProgress({
        phase: "plan",
        attempt,
        steps: plan.length,
      });

      // ───────── Step 2: edit ─────────
      const editResult = await step.do(
        `edit-${attempt}`,
        {
          retries: { limit: 2, delay: "5 seconds", backoff: "exponential" },
          timeout: "10 minutes",
        },
        async () => editor.applyPlan({ issue, plan, conversationId }),
      );
      changedFiles = editResult.changedFiles;
      await this.reportProgress({
        phase: "edit",
        attempt,
        files: changedFiles.length,
      });

      // ───────── Step 3: verify ─────────
      const verify = await step.do(
        `verify-${attempt}`,
        { retries: { limit: 1, delay: "5 seconds" }, timeout: "10 minutes" },
        async () => verifier.runTests({ issue, conversationId, attempt }),
      );
      testSummary = verify.summary;
      await this.reportProgress({
        phase: "verify",
        attempt,
        passed: verify.passed,
      });

      if (verify.passed) {
        // ───────── Step 4: commit to Artifacts ─────────
        const commit = await step.do(
          `commit-${attempt}`,
          { retries: { limit: 3, delay: "5 seconds", backoff: "exponential" } },
          async () =>
            agent.commitToArtifact({
              conversationId,
              branch: `fix/issue-${issue.id}`,
              message: `fix(${issue.id}): ${issue.title}\n\nResolves #${issue.id}`,
            }),
        );

        return {
          ok: true,
          attempts: attempt,
          changedFiles,
          artifactCommit: commit,
          testSummary,
        };
      }

      lastError = verify.summary;
    }

    throw new Error(`SolveIssue exhausted ${MAX_ATTEMPTS} attempts: ${lastError}`);
  }
}

几个看点:

  • step.do(name, opts, fn)name 在 retry 时必须保持稳定,所以带上 attempt 后缀。换名字会让 Workflow 把它当新 step,白白重跑。
  • this.reportProgress({...})AgentWorkflow 基类给的 typed progress API(workflows.d.ts:120-135),内部会 RPC 回主 agent 的 onWorkflowProgress(name, id, progress)(REAL_API_v2 §E.3),进度直接 broadcast 到前端。
  • commitToArtifact 是第 7 章定义在 CoderAgent 上的方法。它在 sandbox 里跑 git commit、然后 git pushenv.ARTIFACTS.create() 给的 remote URL,把 commit SHA 回传。本章直接当方法调,不用注册成 LLM tool —— Workflow 是宿主代码,不是模型上下文。

CoderAgent:接 issue、转交、回收 Workflow 进度

// src/agent.ts(增量,沿用第 7 章已有的 Think 子类)
import { Think } from "@cloudflare/think";
import { callable } from "agents";
import type { SolveIssueParams, SolveIssueResult } from "./workflows/solve-issue";
// ... 第 7 章已有的 imports:getModel/getTools/configureSession/commitToArtifact/...

export class CoderAgent extends Think<Env> {
  async onStart() {
    // 第 7 章已经在建 artifacts 表;这里追加 issues 表
    this.sql`CREATE TABLE IF NOT EXISTS issues (
      id TEXT PRIMARY KEY,
      title TEXT, body TEXT,
      workflow_id TEXT, status TEXT,
      created_at INTEGER
    )`;
  }

  /** 接 issue → 入库 → 起 Workflow。供 HTTP 入口 + LLM tool 共用。 */
  @callable()
  async solve(issue: { id: string; title: string; body: string }) {
    this.sql`
      INSERT INTO issues (id, title, body, status, created_at)
      VALUES (${issue.id}, ${issue.title}, ${issue.body}, 'queued', ${Date.now()})
      ON CONFLICT(id) DO UPDATE SET status = 'queued'
    `;

    const params: SolveIssueParams = { issue, conversationId: this.name };
    const instanceId = await this.runWorkflow("SOLVE_ISSUE", params, {
      metadata: { issueId: issue.id },
    });

    this.sql`UPDATE issues SET workflow_id = ${instanceId} WHERE id = ${issue.id}`;
    return { instanceId };
  }

  async onWorkflowProgress(_name: string, instanceId: string, progress: unknown) {
    this.broadcast(JSON.stringify({ type: "solve-progress", instanceId, progress }));
  }

  async onWorkflowComplete(_name: string, instanceId: string, result?: SolveIssueResult) {
    const issueId = (this.getWorkflow(instanceId)?.metadata as { issueId?: string } | undefined)
      ?.issueId;
    if (issueId) {
      this.sql`UPDATE issues SET status = 'complete' WHERE id = ${issueId}`;
    }
    this.broadcast(JSON.stringify({ type: "solve-complete", instanceId, result }));
  }

  async onWorkflowError(_name: string, instanceId: string, error: string) {
    const issueId = (this.getWorkflow(instanceId)?.metadata as { issueId?: string } | undefined)
      ?.issueId;
    if (issueId) {
      this.sql`UPDATE issues SET status = 'errored' WHERE id = ${issueId}`;
    }
    this.broadcast(JSON.stringify({ type: "solve-error", instanceId, error }));
  }
}

// sub-agent / workflow 类必须在 entry 重导出,DO migration 才能找到
export { PlannerAgent } from "./sub-agents/planner";
export { EditorAgent } from "./sub-agents/editor";
export { VerifierAgent } from "./sub-agents/verifier";
export { SolveIssue } from "./workflows/solve-issue";

HTTP 入口

第 1 章的 routeAgentRequest 入口加一条手动路由,让 POST /api/issues/:id/solve 命中 agent 的 solve():

// src/index.ts(增量,放在 routeAgentRequest 之前)
import { getAgentByName } from "agents";

const m = url.pathname.match(/^\/api\/issues\/([^/]+)\/solve$/);
if (m && request.method === "POST") {
  const issueId = m[1];
  const conv = url.searchParams.get("conversation") ?? "default";
  const body = (await request.json()) as { title: string; body: string };
  const agent = await getAgentByName(env.CODER_AGENT, conv);
  const r = await agent.solve({ id: issueId, ...body });
  return Response.json(r);
}

wrangler 增量

三件事:注册 Workflow bindingFacets 子类放进 DO migrations给 sub-agent 类一个 SQLite 后端

// wrangler.jsonc(增量,只展示新增字段)
{
  // ... 第 7 章已有的 ai / durable_objects / containers / r2_buckets / artifacts 不动
  "workflows": [
    {
      "name": "solve-issue",
      "binding": "SOLVE_ISSUE",
      "class_name": "SolveIssue"
    }
  ],
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["CoderAgent"] },
    { "tag": "v2", "new_sqlite_classes": ["CoderSandbox"] },
    {
      "tag": "v3",
      "new_sqlite_classes": ["PlannerAgent", "EditorAgent", "VerifierAgent"]
    }
  ]
}

Facets 不需要在 durable_objects.bindings 里出现 —— 它们没有顶层路由身份,this.subAgent(EditorAgent, "ed-1") 直接拿 stub。但它们必须migrationsnew_sqlite_classes,这样每个 Facet 的 SQLite 才会被独立分配。

workflows[].class_name 指向 worker 入口 re-export 的 SolveIssue(我们上面已经在 src/agent.ts 导出);binding 名字 SOLVE_ISSUErunWorkflow("SOLVE_ISSUE", ...) 的第一参数对齐。

图 8-3:一次 attempt 的 step 流

一次 attempt 的 4 个 step,失败时把 verify 错误回喂下一个 attempt 的 plan step.do plan PlannerAgent.makePlan step.do edit EditorAgent.applyPlan step.do verify VerifierAgent.runTests step.do commit commitToArtifact passed = true passed = false → previousAttemptError 进下一轮 plan prompt 每个 step.do 的 name 必须稳定 —— attempt 后缀防止 retry 复用旧 step 结果 step.sendEvent("progress",...) → 主 agent.onWorkflowProgress → broadcast 给前端

验证

完整跑一遍。前提:第 6/7 章的 sandbox 已经能用、Artifacts 仓库已经在 conversation 启动时通过 initArtifact 建好(第 7 章 tools/artifact.ts)。

# Terminal —— 先建一个 conversation,顺手 initArtifact + git clone 一个待修复的 repo
curl -X POST 'http://localhost:8787/agents/coder-agent/demo/init' \
  -H 'content-type: application/json' \
  -d '{"sourceRepo":"https://github.com/your/site.git"}'

# Terminal —— 扔 issue 进去
curl -X POST 'http://localhost:8787/api/issues/42/solve?conversation=demo' \
  -H 'content-type: application/json' \
  -d '{
    "title": "Fix README typo",
    "body": "README.md line 7 has Wrokrs which should be Workers."
  }'
# {"instanceId":"wf_01HZX..."}

打开 wrangler dev 的日志,会看到三段 progress 事件:{phase:"plan",steps:1}{phase:"edit",files:1}{phase:"verify",passed:true}。约 30-90 秒后(取决于 npm test 的体量),最终一条:

{
  "type": "solve-complete",
  "result": {
    "ok": true,
    "attempts": 1,
    "changedFiles": ["README.md"],
    "artifactCommit": {
      "sha": "9c4f...e1",
      "ref": "refs/heads/fix/issue-42"
    },
    "testSummary": "Tests:       1 passed, 1 total\n..."
  }
}

去 Workflow 控制台(dash.cloudflare.com → Workers → Workflows → solve-issue)能看到这次 instance 的完整 step 树:plan-1 / edit-1 / verify-1 / commit-1,每个 step 多长、retry 几次、stdout/stderr 一目了然。

故意制造一次失败来看 retry:把 npm test 改成必挂的命令(比如临时往 README 写错字让 lint 失败),重 solve 同一个 issue id,你会看到 verify-1 失败 → plan-2 起来,prompt 里多了一段 Previous attempt failed with: ... —— 这就是“回喂“。

边界与坑

  • step.do name 必须稳定且唯一。重跑时如果换名字(比如忘了带 attempt 后缀),Workflow 会以为是新 step,把成功的旧 step 一起重跑。retry 也认 name —— 同名同 retry。
  • Facets 共享父 DO 的进程 / 内存,不共享 SQL。每个 sub-agent 的 this.sql 都独立,但所有 sub-agent 跟主 agent 一起被驱逐和恢复。所以别在 sub-agent 里持有“必须长期活着“的资源(WebSocket、长进程):它们随父 DO 一起睡。
  • AgentWorkflow.run 不能调 LLM 流式 API。Workflow step 是 deterministic replay 模型,LLM 流必须封在 step.do 里跑完再返回。把 streamText 直接写进 run() 顶层会在恢复时重发请求、烧钱。
  • 每次 attempt 的 subAgent(name) 名字一致才能复用历史。我们用 plan-${issue.id} 而不是 plan-${issue.id}-${attempt} —— 失败重试时 PlannerAgent 能在 plans 表里看到上一次自己写了什么。
  • commitToArtifact 失败的常见原因是 token 过期。Artifacts token 自带 ?expires=(REAL_API_v2 §C.4),长跑 Workflow 跨 token 寿命时,step.do("commit", ...) 内部要重新 env.ARTIFACTS.get(name).createToken() 拿新 token 再 push。
  • Workflow 并发限额是 50000 实例 / 账户、300 创建/秒、2M 队列长度(REAL_API_v2 §E.1)—— 一个用户单会话内串行起 issue,远到不了上限。多租户并发起几千 issue 时再回头看 Workers Limit Request Form

延伸阅读

下一章预告

agent 现在能把 fix push 到自己的 Artifacts 仓库 —— 但用户的 reviewer 在 GitHub。下一章我们让 agent 把 Artifacts 当 origin、GitHub 当 mirror,自动开 PR,然后用 Cloudflare Email Service 给提 issue 的人发邮件通知 PR 已就绪,用户回邮件可直接评论 PR。merge 那一下保留人按。