Joye Dev

Back

fix(agent): surface terminal image failures as results, never throw

**PR:**fix(agent): surface terminal image failures as results, never throw (#4516)


正文来源:飞书学习文档。以下为通过个人 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。

🗂️ 这是知识库中的🔬 研究。

内容可能仍在补充或修订中。

← Back