【免费下载链接】sandcastle
Orchestrate sandboxed coding agents in TypeScript with sandcastle.run()
Sandcastle(sandcastle.run())在编排沙箱化编码 Agent 时,需要从 Agent 的自由文本输出中稳定地抽取机器可读结果。本文以 .sandcastle/agent-workflows/implement-pr/extraction.md 为骨架,完整讲解 implement-pr 工作流的"输出提取契约":Agent 必须如何在响应的最后发射单个<output>块、块内 JSON 的三种字段语义与空数组规则,以及该契约在 implement-pr.ts、run-with-extraction.ts 与 review-output.ts 中的落地实现。读完本文,你将掌握如何为编码 Agent 设计"可解析的输出协议",并能复现 implement-pr 工作流从提取、校验、过滤到落盘的完整链路。
一、extraction.md 契约全文:Agent 必须遵守的输出协议
extraction.md本身是一份面向 Agent 的指令文件(extraction prompt),它定义了 implement-pr 工作流对 Agent 最终回应的硬性约束。全文如下:
Emit a single
<output>block as the last thing in your response.Do not change files. Do not run commands. Do not include text outside the
<output>block.<output> { "threadReplies": [ { "commentId": "GraphQL node id from PR_COMMENTS_JSON", "body": "Markdown reply" } ], "newInlineComments": [ { "path": "relative/file.ts", "line": 123, "body": "Markdown comment" } ], "topLevelComments": [ { "body": "Markdown comment" } ] } </output>Use empty arrays when there are no replies or comments.
逐条拆解这份契约,可以提炼出四个关键约定:
1. 输出位置与唯一性:单个<output>块、位于响应末尾。契约要求 Agent 把<output>作为"最后发射的东西"(the last thing),且全文只允许出现一个该标签。这样下游解析器无需在长文本中做启发式定位,只需锚定最后一个标签即可。
2. 行为禁令:不修改文件、不运行命令、标签外不输出任何文本。三条禁令共同保证:提取阶段(extraction run)是一个"只读、只说话"的会话——Agent 在这一轮的唯一任务就是把上一轮"生产阶段"(produce run)的结论整理成结构化 JSON,而不是再次动代码。这从机制上把"干活"与"汇报"两个阶段彻底分离。
3. JSON 结构:三类 PR 反馈实体。契约给出了可复制的最小骨架,三类字段的语义如下表:
| 字段 | 类型 | 语义 | 关键字段约束 |
|---|---|---|---|
threadReplies | 对象数组 | 对已存在评论线程的回复 | commentId必须是来自PR_COMMENTS_JSON的 GraphQL node id;body为 Markdown 正文 |
newInlineComments | 对象数组 | 新插入的行内评论 | path为仓库内相对文件路径;line为行号(正整数);body为 Markdown 正文 |
topLevelComments | 对象数组 | PR 顶层评论(不挂在具体行/线程上) | 仅需body字段 |
4. 空数组规则:无回复/无评论时用空数组,而不是省略字段或写 null。这条规则让下游校验保持简单——implementPrOutputSchema对缺失字段使用?? []兜底(详见第四节),但契约仍然要求 Agent 显式写出[],保证输出形状永远一致。
值得说明的是,该契约并非 implement-pr 独有。仓库内 explore/extraction.md、review/extraction.md、update-branch/extraction.md 都是同构的"单<output>块 + JSON"协议,区别仅在字段集:例如 review/extraction.md 要求summary、inlineComments、replies三个字段。可以说,"标签包裹 + 固定 JSON 骨架 + 空数组约定"是 Sandcastle Agent 工作流输出提取的通用模式。
二、契约如何被接线:implement-pr.ts 的两阶段调用链
extraction.md不是一份被人工阅读的文档,而是被 implement-pr.ts 在运行时读取并注入 Agent 会话的指令。核心接线代码位于 implement-pr.ts:
const result = await runWithExtraction({ name: `implement-pr-${PR_NUMBER}`, agent: claudeAgent(), sandbox: noSandbox(), logging: { type: "stdout" }, promptFile: path.join(import.meta.dirname, "prompt.md"), promptArgs: { PR_NUMBER, BRANCH, PR_TITLE: context.prTitle, ISSUE_NUMBER: context.issueNumber || "(none)", ISSUE_TITLE: context.issueTitle || "(no linked issue)", LINKED_ISSUE: context.linkedIssue, DIFF_TO_MAIN: context.diff, PR_COMMENTS_JSON: context.prCommentsJson, }, output: sandcastle.Output.object({ tag: "output", schema: implementPrOutputSchema, }), extractionPrompt: fs.readFileSync( path.join(import.meta.dirname, "extraction.md"), "utf8", ), });几个值得注意的细节:
extractionPrompt就是本文主角:extraction.md被fs.readFileSync原样读入,作为提取阶段(extraction run)的prompt传给 Agent;tag: "output"与契约呼应:Output.object的tag指定了要从 Agent stdout 中提取的 XML 标签名,与extraction.md中要求的<output>完全一致;schema: implementPrOutputSchema:标签内的 JSON 会被解析后用 Standard Schema 校验(详见第四节);promptArgs中的PR_COMMENTS_JSON:契约中commentId字段注明"GraphQL node id from PR_COMMENTS_JSON",正是由这里注入——Agent 只能回复 PR 对话中真实存在的线程 id;noSandbox():该工作流运行在无沙箱环境下,Agent 只负责产生提交与评论内容,不负责推送(详见 prompt.md 的 "Do not push" 等禁令)。
生产阶段的提示词 prompt.md 定义了 Agent 的"任务面":处理 PR #{{PR_NUMBER}} 上未解决的评审反馈、链接 issue、当前与 main 的 diff、PR 评论 JSON,并要求 Agent "When complete, output<promise>COMPLETE</promise>"。而 extraction.md 定义的则是"汇报面"——把结果翻译成三类结构化评论。两者构成完整的指令体系。
三、底层机制:runWithExtraction 的两阶段"生产 + 提取"运行
extraction.md之所以敢要求"提取阶段不修改文件、不运行命令",是因为工作流在机制上就把它设计成独立的一轮运行。核心实现见 run-with-extraction.ts:
export async function runWithExtraction<T>( options: RunWithExtractionOptions<T>, ): Promise<RunResult & { output: T }> { const { output, extractionPrompt, maxRetries = 2, ...produceOptions } = options; const produce = await run(produceOptions); const sessionId = produce.iterations.at(-1)?.sessionId; if (!sessionId) { throw new Error( "Cannot extract structured output because the produce run had no session id.", ); } const { promptArgs: _promptArgs, ...extractionOptions } = produceOptions; const extraction = await run({ ...extractionOptions, name: produceOptions.name ? `${produceOptions.name} (extract)` : undefined, promptFile: undefined, prompt: extractionPrompt, resumeSession: sessionId, output: { ...output, maxRetries }, }); return { ...produce, output: extraction.output }; }机制拆解如下:
- 第一轮
run(produceOptions)(生产阶段):使用prompt.md让 Agent 在沙箱/工作区里真正改代码、跑 typecheck、提交 commit。该轮不声明output,Agent 以自由文本形式输出; - 取
sessionId:从生产轮次最后一次迭代的iterations.at(-1)?.sessionId拿到 Agent 会话 id。取不到时直接抛出 "Cannot extract structured output..." 错误,说明该 Provider 不支持会话延续; - 第二轮
run(...)(提取阶段):以resumeSession: sessionId恢复同一个 Agent 会话,但把promptFile替换为prompt: extractionPrompt(即 extraction.md 全文),并挂上output: Output.object({...})。此时 Agent 带着第一轮的全部上下文(已做的修改、PR 对话),但只被要求"把结果整理成<output>块"——因此无需再改文件或跑命令; maxRetries = 2:若提取或校验失败,最多再额外重试 2 次(总计 3 次尝试),每次重试都会恢复会话并把错误反馈给 Agent,让它重新发射修正后的标签;- 返回值:
{ ...produce, output: extraction.output }——调用方同时拿到生产阶段的commits等元数据与提取阶段的结构化output。
从源码注释可以确认(run-with-extraction.ts),maxRetries的默认值是2(三次尝试),它被转发给Output的内置重试机制。这也解释了为什么extraction.md的措辞如此"强势"(last thing、no text outside):一旦 Agent 违反契约导致 JSON 解析失败,整个提取轮次就会触发重试,消耗额外的迭代与 token。
四、Schema 校验:宽松解析 + 严格校验的平衡
契约中的 JSON 骨架由 review-output.ts 中的implementPrOutputSchema负责校验。该 Schema 用standardSchema包装(见 common.ts 的standardSchema辅助函数,返回 Standard Schema v1 格式的校验器,失败时产出issues数组)。
关键设计是"字段别名兼容":
- 行内评论:
parseInlineComment同时接受path或file、body或comment作为键名(review-output.ts),因为不同 Agent 在生成 JSON 时键名可能漂移; - 行号解析:
parseLine先要求line是正整数;若line缺失,则回退到lineRange字段,用正则/\d+/提取其首个数字作为行号(review-output.ts)。这允许 Agent 输出"lineRange": "120-135"这样的区间表示,同时仍能落成单一锚点行; - 顶层评论:
topLevelComments每个元素只要求非空字符串body; - 空值兜底:
threadReplies、newInlineComments、topLevelComments均使用record.xxx ?? []兜底,即使 Agent 违背契约省略了字段也不会直接崩坏。
这套"宽松解析、严格语义"的策略,配合 extraction.md 的"空数组"约定,让最终落盘的数据结构始终是三个固定形状的数组。
五、输出后处理:可信过滤 + 兜底失败 + 结果落盘
提取出的结构化输出并不会被直接使用——它要先经过两层可信度过滤,再由 implement-pr.ts 落盘:
第一层:回复过滤filterReplies。Agent 声称要回复的commentId必须真实存在于拉取到的未解决评审线程中。validReplyIds由 review-context.ts 构造:它通过 GitHub GraphQL API 查询reviewThreads,仅收集isResolved === false的线程评论 id。不在集合中的回复会被丢弃并打印Dropping reply for commentId=...警告(review-output.ts)。
第二层:行内评论过滤filterInlineComments。新行内评论的path/line必须落在当前 diff 内。diffLines由 diff-lines.ts 的parseDiffLines从git diff main...HEAD解析而来:它跟踪+++ b/后的文件路径、@@ -a,b +c,d @@的起始新行号,并把+开头行与上下文行(空格开头或空行)计入可评论行集合。落在 diff 之外的评论(文件不在 diff 中、或行号不在 hunk 内)会被丢弃并打印警告(review-output.ts)。这一步非常关键——GitHub 不允许对未变更代码行发表行内评论,过滤保证了后续提交流程不会被 API 拒绝。
兜底失败判定。若 Agent 既没产生提交(result.commits.length === 0)、又没有通过过滤的回复/行内评论/顶层评论,工作流调用fail(...)终止并把原因写入failure_reason.txt(implement-pr.ts、common.ts)。也就是说,"空输出"被视为一种需要显式暴露的异常状态。
结果落盘。过滤后的数据被写成四个文件(目录由环境变量OUTPUT_DIR指定,默认/tmp,见 common.ts):
| 文件 | 内容 |
|---|---|
has_commits.txt | "true"/"false",标识 Agent 是否产生提交 |
implement_thread_replies.json | 过滤后的threadReplies |
implement_new_inline_comments.json | 过滤后的newInlineComments |
implement_top_level_comments.json | topLevelComments |
最终控制台会打印四项统计(commits / thread replies / inline comments / top-level comments 的数量),供编排层或人工查看(implement-pr.ts)。
六、与 Sandcastle 核心Output.object的底层联系
tag: "output"与extraction.md中的<output>标签,最终由 Sandcastle 核心模块 src/Output.ts 支撑。核心语义如下:
Output.object({ tag, schema, maxRetries })返回一个带_tag: "object"品牌的输出定义,run()据此从 Agent stdout 中提取指定标签内容,做fence-aware 的 JSON 解析(即自动剥掉代码围栏再解析),并交给 Standard Schema 校验器验证(src/Output.ts);maxRetries的语义是"首次之后的额外尝试次数",每次重试都会恢复失败的 Agent 会话,并反馈 token 高效的错误描述让 Agent 重新发射修正标签;默认值为0(src/Output.ts)。在 implement-pr 工作流中该值被 run-with-extraction.ts 覆盖为2;- 重试的前置条件:重试要求 Agent Provider 支持会话恢复(即
provider.sessionStorage已填充——如 Claude Code、Codex、Pi)。若请求了重试但 Provider 无法恢复会话,run()会在入口处以明确错误失败(src/Output.ts)。
这与 extraction.md 的约束形成闭环:契约(extraction.md)约束 Agent 的说话方式,Output.object约束解析器的提取方式,implementPrOutputSchema约束数据的形状,三层共同保证"自由文本 → 稳定 JSON"的可靠性。
七、运行前提与实战建议
基于源码可确认,运行 implement-pr 工作流需要满足以下环境前提(implement-pr.ts、review-context.ts):
| 环境变量 / 依赖 | 用途 |
|---|---|
PR_NUMBER | 必填,目标 PR 编号 |
BRANCH | 必填,Agent 工作的分支名 |
CLAUDE_CODE_OAUTH_TOKEN | claudeAgent()使用 Claude Code(claude-opus-4-8)所需的 OAuth token(common.ts) |
GH_REPO | 形如owner/repo,GraphQL 查询reviewThreads时解析仓库归属 |
ghCLI | 拉取 PR 视图、reviews、GraphQL 线程与 issue 信息 |
OUTPUT_DIR(可选) | 结果文件输出目录,默认/tmp |
对希望复用此模式的开发者,几条实战建议:
- 提取契约必须与 schema 同步维护:
extraction.md中的字段名与implementPrOutputSchema的解析逻辑一一对应,修改一侧务必同步另一侧;若想容忍字段漂移,可借鉴path/file、body/comment的别名策略; - 过滤逻辑是生产可用性的关键:不要直接信任 Agent 输出的
commentId与行号,务必像 review-output.ts 那样用真实线程 id 集合与 diff 行集合做二次校验,否则后续 GitHub API 调用会失败; - 把"汇报"与"干活"分成两轮:
runWithExtraction的"生产 + 恢复会话提取"模式,让 Agent 既拥有完整上下文、又不至于在汇报阶段引入副作用,是值得在自定义工作流中复用的架构。
结语
一份仅二十余行的 extraction.md,背后串联着两阶段运行(run-with-extraction.ts)、标签提取与重试(src/Output.ts)、Schema 校验(review-output.ts)与可信过滤(diff 解析 diff-lines.ts、线程集合 review-context.ts)四层机制。理解这份契约,就等于理解了 Sandcastle 如何让不可控的 Agent 自由文本,变成可校验、可过滤、可落盘的结构化数据——这也是所有 Agent 自动化工作流可靠性的基石。
【免费下载链接】sandcastle
Orchestrate sandboxed coding agents in TypeScript with sandcastle.run()
相关推荐
sandcastle 结构化输出提取协议:以 explore 工作流的 `<output>` 契约为例
sandcastle 结构化输出提取协议:以 explore 工作流的 <output 契约为例 导读 本文围绕 .sandcastle/agent workf
Sandcastle 代码评审 Agent 的结构化输出协议:从 `<output>` 块到 GitHub Review 载荷
Sandcastle 代码评审 Agent 的结构化输出协议:从 <output 块到 GitHub Review 载荷 导读 本文围绕 sandcastle
深入解析 Sandcastle implement-pr 工作流:用 Agent 自动消化 PR 评审反馈的提示词设计与实现
深入解析 Sandcastle implement pr 工作流:用 Agent 自动消化 PR 评审反馈的提示词设计与实现 本篇文章围绕 Sandcastle
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考