news 2026/9/26 14:34:22

Sandcastle implement-pr 工作流的结构化输出提取契约:`<output>` 标签 JSON 协议与源码实现解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Sandcastle implement-pr 工作流的结构化输出提取契约:`<output>` 标签 JSON 协议与源码实现解析

【免费下载链接】sandcastle

Orchestrate sandboxed coding agents in TypeScript with sandcastle.run()

项目地址:https://gitcode.com/gh_mirrors/sandcastl/sandcastle
点击查看免费下载

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 }; }

机制拆解如下:

  1. 第一轮run(produceOptions)(生产阶段):使用prompt.md让 Agent 在沙箱/工作区里真正改代码、跑 typecheck、提交 commit。该轮不声明output,Agent 以自由文本形式输出;
  2. 取sessionId:从生产轮次最后一次迭代的iterations.at(-1)?.sessionId拿到 Agent 会话 id。取不到时直接抛出 "Cannot extract structured output..." 错误,说明该 Provider 不支持会话延续;
  3. 第二轮run(...)(提取阶段):以resumeSession: sessionId恢复同一个 Agent 会话,但把promptFile替换为prompt: extractionPrompt(即 extraction.md 全文),并挂上output: Output.object({...})。此时 Agent 带着第一轮的全部上下文(已做的修改、PR 对话),但只被要求"把结果整理成<output>块"——因此无需再改文件或跑命令;
  4. maxRetries = 2:若提取或校验失败,最多再额外重试 2 次(总计 3 次尝试),每次重试都会恢复会话并把错误反馈给 Agent,让它重新发射修正后的标签;
  5. 返回值:{ ...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.jsontopLevelComments

最终控制台会打印四项统计(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_TOKENclaudeAgent()使用 Claude Code(claude-opus-4-8)所需的 OAuth token(common.ts)
GH_REPO形如owner/repo,GraphQL 查询reviewThreads时解析仓库归属
ghCLI拉取 PR 视图、reviews、GraphQL 线程与 issue 信息
OUTPUT_DIR(可选)结果文件输出目录,默认/tmp

对希望复用此模式的开发者,几条实战建议:

  1. 提取契约必须与 schema 同步维护:extraction.md中的字段名与implementPrOutputSchema的解析逻辑一一对应,修改一侧务必同步另一侧;若想容忍字段漂移,可借鉴path/file、body/comment的别名策略;
  2. 过滤逻辑是生产可用性的关键:不要直接信任 Agent 输出的commentId与行号,务必像 review-output.ts 那样用真实线程 id 集合与 diff 行集合做二次校验,否则后续 GitHub API 调用会失败;
  3. 把"汇报"与"干活"分成两轮: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()

项目地址:https://gitcode.com/gh_mirrors/sandcastl/sandcastle
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 14:33:13

从 OpenClaw 到 Hermes Agent:一份可复制的上手指南与 TaoToken 配置骨架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 14:32:10

C# 水晶报表绑定数据并实现打印:条形码配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 14:29:22

AutoJs 通过 shell 操作 sqlite 数据库:增删改查与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 14:27:05

Claude Code模板体系实战:从Prompt到CLAUDE.md的协作标准化

1. 模板不是prompt&#xff1a;claude-code-templates到底解决什么问题 1.1 从"直接对话"到"模板化协作"的转变 用过Claude Code的人应该都有过这种体验&#xff1a;同一个任务&#xff0c;比如"给这个项目补一个数据库迁移脚本"&#xff0c;你…

作者头像 李华
网站建设 2026/9/26 14:25:50

Tripo AI生成3D模型实战:独立开发者游戏工具链效率提升指南

1. 从Tripo切入AI游戏工具链&#xff1a;一个独立开发者的视角 第一次在游戏开发群里看到有人讨论Tripo&#xff0c;是去年底的事。当时一个做独立游戏的朋友发了一张截图&#xff0c;展示他如何用一段文字描述在几分钟内生成了一个带贴图的3D角色模型&#xff0c;然后直接拖进…

作者头像 李华