fix(agent): resolve image preview URLs from assetIds at replay time
文件: backend/workers/agent/src/agent.ts:822-840。设计点是 resolver 的生命周期绑定到单个 agent turn,而不是绑定到 transcript 或工具全局状态。它保证 URL 是当轮材料,不是历史事实
source_automation: “voyager-merged-pr” run_date: “2026-07-14” anchor_pr_number: 4720 pr_number: 4720 pr_title: “fix(agent): resolve image preview URLs from assetIds at replay time” pr_url: “https://github.com/adastralab-ai/voyager/pull/4720 ↗” author: “Horcrux / magicismight” merged_at: “2026-07-13T17:55:02Z” modules: [“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”] files_changed: 20 learning_tags: [“agent-runtime”,“asset-replay”,“signed-url-lifetime”,“transcript-schema”,“tool-output-contract”,“prompt-cache”,“reliability-test”] business_line: null related_prs: [4309,4344,4516,4389,4580,4598] line_stage: null open_questions: [] feishu_doc_url: “https://my.feishu.cn/docx/YJk2d5lD9ozLtDxmWRhcDRXLnIb ↗”#
正文来源:飞书学习文档 ↗。以下为通过个人 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
为什么值得学#
- 它修的是一个典型的 AI agent 持久化边界问题:DO transcript 里持久化了 4-6 小时有效的签名 asset URL,后续 turn replay 时模型 provider 抓不到过期 URL,会让整个请求被拒,导致对话永久不可继续。PR 的修复不是延长 TTL,而是把持久化形态收敛为 durable assetId,把给模型看的 URL 改成每个 turn 临时铸造。
- 它把 replay-time 刷新和 execute-time prime 放在同一个 AssetResolver 里。历史消息开 turn 前批量解析,当前 turn 新生成或 view 的 asset 在工具执行时 prime,toModelOutput 只从 resolver 取 URL;这个设计让所有图片工具共享一条输出路径。
- 它接受降级而不是失败:历史 asset 刷新失败时不让 turn 崩掉,而是输出 view_asset 提示文本;但用户 abort 仍按取消控制流抛出。这里的错误分流很克制。
- 它顺手收窄了多个 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 在边界处重新物化。
我会怎么吸收#
- 凡是要进长期 transcript、event log、job state 的字段,都先问它是不是可重放的事实;签名 URL、临时路径、provider-specific handles 默认不应该持久化。
- 把 replay 修复放在协议边界,而不是散落到每个调用点。这里的 AssetResolver 是单 turn 的 materialization layer,调用方只认 assetId。
- 测试要覆盖 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 生命周期和测试策略,学习密度最高。