Joye Dev

Back

fix(agent): resolve image preview URLs from assetIds at replay time

文件: backend/workers/agent/src/agent.ts:822-840。设计点是 resolver 的生命周期绑定到单个 agent turn,而不是绑定到 transcript 或工具全局状态。它保证 URL 是当轮材料,不是历史事实


正文来源:飞书学习文档。以下为通过个人 Feishu API 获取并转换后的完整 Markdown 正文。

Voyager Merged PR 学习 - 2026-07-14 - PR #4720#

今日选择#

  • PR: fix(agent): resolve image preview URLs from assetIds at replay time (#4720)
  • 作者: Horcrux / magicismight
  • Merge 时间: 2026-07-13T17:55:02Z (2026-07-14 03:55:02 Australia/Melbourne)
  • 链接: https://github.com/adastralab-ai/voyager/pull/4720
  • 模块: backend/workers/agent/src/agent.ts, backend/workers/agent/src/tools/assetRefresh.ts, backend/workers/agent/src/tools/assetResolver.ts, backend/workers/agent/src/tools/imageTaskTool.ts, backend/workers/agent/src/tools/getBrandKitTool.ts, backend/workers/agent/src/tools/toolDefs.ts
  • 学习标签: agent-runtime, asset-replay, signed-url-lifetime, transcript-schema, tool-output-contract, prompt-cache, reliability-test

为什么值得学#

  1. 它修的是一个典型的 AI agent 持久化边界问题:DO transcript 里持久化了 4-6 小时有效的签名 asset URL,后续 turn replay 时模型 provider 抓不到过期 URL,会让整个请求被拒,导致对话永久不可继续。PR 的修复不是延长 TTL,而是把持久化形态收敛为 durable assetId,把给模型看的 URL 改成每个 turn 临时铸造。
  2. 它把 replay-time 刷新和 execute-time prime 放在同一个 AssetResolver 里。历史消息开 turn 前批量解析,当前 turn 新生成或 view 的 asset 在工具执行时 prime,toModelOutput 只从 resolver 取 URL;这个设计让所有图片工具共享一条输出路径。
  3. 它接受降级而不是失败:历史 asset 刷新失败时不让 turn 崩掉,而是输出 view_asset 提示文本;但用户 abort 仍按取消控制流抛出。这里的错误分流很克制。
  4. 它顺手收窄了多个 tool output schema,移除 previewUrl/urlExpiresAt 和完整 asset 对象,降低以后再次把短期 URL 写进 transcript 的概率。

关键代码#

1. turn 开始时创建 resolver 并刷新历史 asset#

const assetResolver = new AssetResolver();
await refreshHistoryAssets(assetResolver, { env: this.env, cookieHeader, workspaceId, signal: options?.abortSignal }, messages);
const tools = buildAgentTools({ ..., assetResolver, ... });
plaintext

文件: backend/workers/agent/src/agent.ts:822-840。设计点是 resolver 的生命周期绑定到单个 agent turn,而不是绑定到 transcript 或工具全局状态。它保证 URL 是当轮材料,不是历史事实。

2. transcript 只存 assetId,resolver 负责把 assetId 映射成当轮 URL#

export class AssetResolver {
  private urls = new Map<string, string>();
  primeFromAsset(asset: Asset): void {
    const rendition = asset.imageRenditions?.find((candidate) => candidate.name === SCREEN_SMALL_RENDITION);
    this.prime(asset.id, rendition?.url ?? asset.url);
  }
}
export function buildAssetImagePart(assetId: string, resolver: AssetResolver): AssetImagePart {
  const url = resolver.get(assetId);
  if (url) return { type: "image-url", url };
  return { type: "text", text: `Preview unavailable. Use view_asset with assetId "${assetId}".` };
}
plaintext

文件: backend/workers/agent/src/tools/assetResolver.ts:15-45。这里没有 urlExpiresAt 检查,因为 resolver 只接收 turn 内新铸造的 URL;如果没有 URL,主动退成文本而不是提交一个 provider 无法抓取的 image-url。

3. replay 刷新只收集会重新进入模型上下文的 assetId#

if (part.type === "tool-image_generation" || part.type === "tool-remove_background") {
  const output = imageTaskOutputSchema.safeParse(part.output);
  if (output.success && output.data.status === "succeeded") ids.add(output.data.assetId);
} else if (part.type === "tool-view_asset") {
  const input = viewAssetToolDef.inputSchema.safeParse(part.input);
  if (input.success) ids.add(input.data.assetId);
} else if (part.type === "tool-get_brand_kit") {
  const output = getBrandKitToolDef.outputSchema.safeParse(part.output);
  if (output.success) for (const id of surfacedBrandAssetIds(output.data)) ids.add(id);
}
plaintext

文件: backend/workers/agent/src/tools/assetRefresh.ts:20-35。它没有盲扫所有字段,而是按 toModelOutput 会重新附加的工具输出类型收集 assetId;这能控制上下文成本,也避免把不需要视觉 replay 的资产重新解析。

4. tool output schema 去掉短期 URL 字段#

export const imageTaskOutputSchema = z.union([
  z.object({
    status: z.literal("succeeded"),
    assetId: z.string().describe("Durable workspace asset ID. Use it via http://assets.local/<assetId> in HTML."),
    width: z.number().int().positive().optional(),
    height: z.number().int().positive().optional(),
  }),
  ...
]);

export const viewAssetToolDef = {
  ...,
  outputSchema: z.object({ assetId: z.string() }),
};
plaintext

文件: backend/workers/agent/src/tools/toolDefs.ts:190-201 和 368-372。关键是把“持久输出”从“模型可见 multimodal 内容”里拆出来;schema 本身禁止 previewUrl 再被写入长期 transcript。

和最近学习记录的关系#

这和最近几条 agent/image 主题有直接关系:#4309/#4344/#4516/#4389/#4580 都在收敛 image generation 的模型能力、tool schema、终态失败语义和用户确认边界;#4720 继续处理同一条 agent-runtime/image pipeline,但焦点从“模型是否该调用/重试工具”转到“历史 tool output replay 时给 provider 的 multimodal 输入是否仍然有效”。它也和 #4598 有方法论上的相似点:两者都把可变、短期、外部表象从 durable source of truth 里剥离,改用语义 ID/patch 在边界处重新物化。

我会怎么吸收#

  1. 凡是要进长期 transcript、event log、job state 的字段,都先问它是不是可重放的事实;签名 URL、临时路径、provider-specific handles 默认不应该持久化。
  2. 把 replay 修复放在协议边界,而不是散落到每个调用点。这里的 AssetResolver 是单 turn 的 materialization layer,调用方只认 assetId。
  3. 测试要覆盖 replay 语义,而不只是当前请求成功。这个 PR 的 assetRefresh.test 明确断言 dedupe、失败降级、abort 传播,正好覆盖事故路径。

边界/风险#

主要风险是旧 transcript 迁移不在代码里,PR 说明依赖上线前一次性 backfill;如果部署和迁移窗口里仍有旧 shape,可能需要确保旧数据不会再次进入 provider。另一个边界是 refreshHistoryAssets 失败时只降级为 view_asset 提示,可靠性更好但模型会失去视觉上下文;这比 turn 永久失败可接受,但对依赖历史图像判断的任务会有质量损失。测试层面看到了 replay 刷新单测和相关 tool 单测,未看到端到端 provider replay 测试;考虑到事故来自 Anthropic 抓取失败,最好保留线上回归手册或 eval case。

飞书文档#

本节由 automation 创建后补充链接。

候选说明#

Melbourne 今日窗口内看到 9 个 merged PR:#4734、#4733、#4727、#4724、#4720、#4716、#4709、#4695、#4559。没有因为去重跳过今日 PR;已跳过日志中的 #4598 等历史 PR。#4727 有 tool 职责收敛价值,#4559 有 BrandPanel 与 editor insertion 复用价值,但 #4720 同时涉及线上事故、agent replay、tool output contract、asset URL 生命周期和测试策略,学习密度最高。

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

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

← Back