fix(agent): surface terminal image failures as results, never throw
**PR:**fix(agent): surface terminal image failures as results, never throw (#4516)
source_automation: “voyager-merged-pr” run_date: “2026-07-08” anchor_pr_number: 4516 pr_number: 4516 pr_title: “fix(agent): surface terminal image failures as results, never throw” pr_url: “https://github.com/adastralab-ai/voyager/pull/4516 ↗” author: “Horcrux / magicismight” merged_at: “2026-07-08T05:07:53Z” modules: [“agent-eval/cases/image”,“backend/workers/agent/src/tools”,“backend/workers/agent/src/routes/agentEval.ts”,“backend/workers/agent/src/prompts.ts”,“packages/site/src/app/(main)/_agent/chat”] files_changed: 11 learning_tags: [“agent-runtime”,“image-generation”,“terminal-result”,“retry-semantics”,“tool-schema”,“toModelOutput”,“agent-eval”] business_line: null related_prs: [4314,4344,4309,4390] line_stage: null open_questions: [] feishu_doc_url: “https://my.feishu.cn/docx/Ckttd7v9QoaoTRx2gbucDzFfnge ↗”#
正文来源:飞书学习文档 ↗。以下为通过个人 Feishu API 获取并转换后的完整 Markdown 正文。
今日选择#
**PR:**fix(agent): surface terminal image failures as results, never throw (#4516)
**作者:**Horcrux / magicismight
**Merge 时间:**2026-07-08T05:07:53Z
链接:https://github.com/adastralab-ai/voyager/pull/4516 ↗
**模块:**backend/workers/agent image task tools, tool schema, prompts, agent eval, site chat tool parts。
**学习标签:**agent-runtime, image-generation, terminal-result, retry-semantics, tool-schema, toModelOutput, agent-eval。
为什么值得学#
- 它改的是 agent/tool boundary 的错误语义:终态业务失败不再走 throw,因为 AI SDK 会把 throw 变成可重试 tool-error,模型会重复调用或绕过限制。
- 它把多种失败统一成 discriminated union:success 和 failed{reason} 是同一个工具输出契约,credits、resolution、no_image、error 各带自己的字段。
- 它没有只改 runtime,还同步收紧 prompt、UI 渲染和 eval harness,避免生产路径、模型可见输出和评测 mock 三者分叉。
- 它延续最近几天 Nano Banana Lite / image tool 链路:模型能力、plan gate、任务 worker 和 agent chat 都开始用“结构化终态结果”表达边界。
关键代码#
1. 失败结果按 reason 进入模型视图#
if (output.status === "failed") {
if (output.reason === "resolution") {
return {
type: "text",
value:
resolution === "source-sized"
? `The source image is ${output.requested}, which exceeds this plan's ${output.limited} cap. Tell the user the image is too large for their plan and to try a smaller one.`
: `This image request needs ${output.requested} resolution, which exceeds this plan's ${output.limited} cap. Tell the user they can use a lower resolution or upgrade their plan for higher resolutions.`,
};
}
return { type: "json", value: output };
}plaintext设计点:resolution 是唯一需要 caller context 的失败,所以转成语境化文本;其他失败保留结构化 JSON,让模型直接读字段,不再靠字符串解析。
2. submit gate 和 worker terminal failure 都返回值#
if (submitResult.status !== "submitted") {
if (submitResult.status === "insufficient_credits") {
return {
status: "failed",
reason: "credits",
canUpgrade: submitResult.canUpgrade,
...(submitResult.refreshInSeconds != null
? { refreshInSeconds: submitResult.refreshInSeconds }
: {}),
};
}
return {
status: "failed",
reason: "resolution",
requested: submitResult.requested,
limited: submitResult.limited,
};
}
if (outcome.status === "failed") {
await store.delete(idempotencyKey);
return {
status: "failed",
reason: "error",
...(outcome.errorCode != null ? { errorCode: outcome.errorCode } : {}),
};
}plaintext设计点:无任务的 gate 和已有任务的 terminal failure 都是“完成的工具调用”,不是异常。真正 transient 的 poll/SDK error 仍保留 throw 和 pending task 记录。
3. 输出 schema 从状态枚举升级成失败判别联合#
export const imageTaskOutputSchema = z.union([
z.object({
status: z.literal("succeeded"),
assetId: z.string(),
previewUrl: z.url(),
previewUrlExpiresAt: z.string().optional(),
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
}),
z.object({ status: z.literal("failed"), reason: z.literal("credits"), canUpgrade: z.boolean(), refreshInSeconds: z.number().optional() }),
z.object({ status: z.literal("failed"), reason: z.literal("resolution"), requested: z.string(), limited: z.string() }),
z.object({ status: z.literal("failed"), reason: z.literal("no_image"), message: z.string() }),
z.object({ status: z.literal("failed"), reason: z.literal("error"), errorCode: z.string().optional() }),
]);plaintext设计点:schema 本身表达“终态失败”的类别和字段归属,避免旧的 insufficient_credits / resolution_limit / no_image 平铺状态在 UI、模型输出、测试里各自分支。
4. eval case 固化“不要重试”行为#
- description: "image-task-error: server task failure is surfaced, agent does not retry"
vars:
message: "Generate an image of a mountain lake."
mockTools:
image_generation:
status: "failed"
reason: "error"
assert:
- type: trajectory:tool-used
value: image_generation
- type: llm-rubric
value: "The reply tells the user the image could not be generated due to a server error. It must NOT claim an image was created, added, or placed on the page."plaintext设计点:这里测的不是 UI,而是 agent trajectory 和回复语义,正好覆盖这类 runtime 边界最容易回归的地方。
和最近学习记录的关系#
有直接关系。7 月 4 日 #4314 把 gpt-image-2 迁到 AI Gateway 路径,7 月 5 日 #4344 把附件图作为 referenceAssetIds 传入图像任务,7 月 7 日 #4309 把 Nano Banana Lite 作为跨层模型契约接进 API、agent runtime 和 UI。#4516 是这个链路的可靠性补齐:当图像任务不能按用户要求完成时,agent 不能把失败当作可重试异常,也不能自行降级参数消耗积分。
我会怎么吸收#
- LLM tool 的失败语义要先分类:terminal business failure 应该是 value,transient infra failure 才是 exception。
- 输出 schema 要让模型、UI、测试共享同一个契约;不要让 UI 状态、prompt 说明和 eval mock 各自发明失败格式。
- 对会收费或有副作用的工具,重试策略要在工具结果和系统 prompt 两层同时约束,尤其要禁止模型“换参数绕过限制”。
边界/风险#
未看到明显代码风险。需要继续关注的是:模型是否稳定理解 {status:"failed", reason:"error"} 这类 JSON 输出;PR 已通过 eval case 覆盖主路径,但真实模型仍可能需要更多失败样本做回归集。
候选说明#
今天 Melbourne 当天窗口内看到 20 个 merged PR;昨天窗口也有候选,但今天已有足够高质量目标,所以没有回退。最终选 #4516,是因为它比纯 release、样式、依赖升级更有 runtime boundary 学习价值,并且和最近 image-generation / agent-runtime 学习记录强相关。去重日志中已记录 #4309、#4390、#4344、#4314、#4124、#4156,因此没有重复选择这些 PR。