第 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 的全景
入口路由把 issue 落进 CoderAgent 的 this.sql,然后立刻把执行权交给 Workflow,长任务在 Worker 单次调用之外活着。三个 sub-agent 是 Facets(Project Think 的同进程子 DO),各自一份 SQLite,主 agent 只看结果。
为什么
如果只在 onChatMessage 里 await 一长串工具调用,会撞上三堵墙。
第一,时长。Workers 单次 invocation 走完 30 秒就该让位。一次“读 issue → 列文件 → 改代码 → npm install → npm 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 跑:PlannerAgent、EditorAgent、VerifierAgent。它们都是 CoderAgent 的 Facet(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 的分工
落地
整套改动分四块:think 工具、三个 Facet sub-agent、SolveIssue 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 push到env.ARTIFACTS.create()给的remoteURL,把 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 binding、Facets 子类放进 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。但它们必须进 migrations 的 new_sqlite_classes,这样每个 Facet 的 SQLite 才会被独立分配。
workflows[].class_name 指向 worker 入口 re-export 的 SolveIssue(我们上面已经在 src/agent.ts 导出);binding 名字 SOLVE_ISSUE 与 runWorkflow("SOLVE_ISSUE", ...) 的第一参数对齐。
图 8-3:一次 attempt 的 step 流
验证
完整跑一遍。前提:第 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.doname 必须稳定且唯一。重跑时如果换名字(比如忘了带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。
延伸阅读
- Workflows v2 公告 — 控制平面重构,SousChef + Gatekeeper
- Durable Object Facets — 同进程子 DO 的设计动机
- Agents Workflows API —
AgentWorkflow/runWorkflow/onWorkflowProgress - Sandbox API reference —
writeFile/exec/gitCheckout完整签名
下一章预告
agent 现在能把 fix push 到自己的 Artifacts 仓库 —— 但用户的 reviewer 在 GitHub。下一章我们让 agent 把 Artifacts 当 origin、GitHub 当 mirror,自动开 PR,然后用 Cloudflare Email Service 给提 issue 的人发邮件通知 PR 已就绪,用户回邮件可直接评论 PR。merge 那一下保留人按。