fix(agent): finish verified edits after ancillary download actions

This commit is contained in:
134 2026-10-21 13:58:55 +08:00
parent e264e9136a
commit 05ff5069f9
5 changed files with 35 additions and 5 deletions

View File

@ -1,4 +1,12 @@
import type { AgentRunSnapshot } from "./protocol";
import type { AgentCallRecord, AgentRunSnapshot } from "./protocol";
// A spontaneous download command is not an unfinished image edit. Keep genuine
// download requests and all editor panels waiting for their actual interaction.
export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallRecord) {
return call.name === "image_download" && call.status === "succeeded" &&
call.output?.mode === "interactive" && finalResultNodeIds(snapshot).length > 0 &&
!/下载|导出|保存.*(?:本地|电脑)|download|export/i.test([snapshot.objective, ...snapshot.inputs.map(input => input.text)].join("\n"));
}
export function finalImageResultNodeIds(
snapshot: AgentRunSnapshot,

View File

@ -5,7 +5,7 @@ import sharp from "sharp";
import { z } from "zod";
import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas";
import type { AgentRunSnapshot } from "./protocol";
import { finalResultNodeIds, originalSourceNodeIds } from "./results";
import { finalResultNodeIds, originalSourceNodeIds, isAncillaryDownload } from "./results";
import { isCompletedSimpleCutout } from "./cutout-completion";
export async function inspectImageAlpha(source: Buffer) {
@ -85,7 +85,7 @@ export async function analyzeRuntimeImages(params: {
],
// A prompt alone cannot enforce machine-readable vision output. Ask the
// compatible API for JSON, then validate the actual returned object.
experimental_output: Output.object({ schema: reviewSchema }),
experimental_output: { ...Output.object({ schema: reviewSchema }), responseFormat: { type: "json" as const } },
providerOptions: { openai: { structuredOutputs: false } },
maxOutputTokens: 2400,
maxRetries: 0,
@ -123,7 +123,7 @@ export async function verifyCanvasAgentResults(params: {
const incomplete = calls.filter(
(call) =>
call.status !== "succeeded" ||
call.output?.mode === "interactive" ||
(call.output?.mode === "interactive" && !isAncillaryDownload(snapshot, call)) ||
(typeof call.output?.status === "string" && call.output.status !== "succeeded"),
);
if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] };

View File

@ -6,6 +6,7 @@ import { runNativeZcode } from "./native-runtime";
import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop";
import { AgentToolRejectedError, publicAgentError, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion";
import { isAncillaryDownload } from "@/lib/ai/canvas-agent-runtime/results";
type Ports = {
native?: typeof runNativeZcode;
@ -56,6 +57,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
"你正在处理本站 Agent 任务。本站已授权的图片、节点、界面及项目业务能力通过 canvas MCP 提供;视频画布暂未授权,不得绕过限制;先读实际画布,不猜测节点 ID。",
`可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`,
"原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。",
"用户只要求生图或改图时,把结果写入画布并验收即可。除非用户明确要求下载或导出,不要主动调用 image_download,也不要为收尾打开交互面板。",
"imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。自动选择生图模型时,图片工具内部会在明确失败后切换备用模型并补齐结果,不要自行重放付费操作;用户明确指定模型时禁止故障切换。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。",
"转正面、换角度、旋转主体视角时,直接用 generateCanvasImages,referenceNodeIds 指向要编辑的真实图片,提示词描述目标视角并保留产品结构和未要求修改的细节。旧多视角功能、angleEditImage 和 image.multi_angle 已废弃,即使历史对话用过也不得再调用或打开旧面板。",
"查询项目级能力先用 listProjectServices;技能先 loadProjectSkill。项目工具的任务受理、文件选择与后台缺失不等于处理完成。",
@ -199,7 +201,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
// cannot turn a failed analysis into a completed task.
if (unresolvedRead && !snapshot.resultNodeIds.length)
return await save("图片或资料暂未读取成功,已保留记录,请稍后继续核对。", "needs_review");
if (question || snapshot.calls.some(call => call.output?.mode === "interactive"))
if (question || snapshot.calls.some(call => call.output?.mode === "interactive" && !isAncillaryDownload(snapshot, call)))
return await save(question || "请在已打开的工具面板中完成操作,然后继续任务。", "needs_review");
const supplements = snapshot.inputs.filter(input => !startedInputs.has(input.id));
if (supplements.length) { prompt = supplements.map(input => input.text).join("\n"); continue; }

View File

@ -23,6 +23,16 @@ function fixture(objective = '修改图片') {
const result = { type: 'result', sessionId: 'sess_fixture', response: '已完成' };
async function main() {
let checks = 0;
for (const scenario of ["verified", "rejected-result", "download-requested", "no-result", "editor-panel"]) {
const h = fixture(scenario === "download-requested" ? "修改并下载图片" : "把柜子角度改为正面");
if (scenario !== "no-result") h.snapshot.resultNodeIds = ["real-result"];
h.snapshot.calls.push({ id: "delivery", name: scenario === "editor-panel" ? "image_crop" : "image_download", input: {}, status: "succeeded", output: { mode: "interactive" } });
let reviews = 0;
h.ports.verify = async () => { reviews++; return { passed: scenario !== "rejected-result", issues: scenario === "rejected-result" ? ["结果不是正面"] : [] }; };
assert.equal(await runZcodeCanvasTask(h.ports), scenario === "verified" ? "completed" : "needs_review");
assert.equal(reviews, ["verified", "rejected-result"].includes(scenario) ? 1 : 0);
checks++;
}
{
const h = fixture(); h.ports.native = async options => {
await options.execute('planCanvasTask', {}, 'plan', options.signal!);

View File

@ -137,6 +137,16 @@ test("a filtered structured review is a policy rejection, never a reason to swit
expect(failures).toEqual([]);
expect(f.requests).toHaveLength(1);
});
test("an unsolicited download cannot block verified image results; requested downloads and real editor panels still wait", async () => {
const f = await fixture('{"passed":true,"issues":[]}');
f.params.snapshot.calls.push({ id: "download", name: "image_download", input: {}, status: "succeeded", output: { mode: "interactive" } });
expect((await verifyCanvasAgentResults(f.params)).passed).toBe(true);
f.params.snapshot.objective += "并下载到电脑";
expect((await verifyCanvasAgentResults(f.params)).passed).toBe(false);
f.params.snapshot.objective = "修改图片";
f.params.snapshot.calls.at(-1)!.name = "image_crop";
expect((await verifyCanvasAgentResults(f.params)).passed).toBe(false);
});
test("reported problems cannot be ignored by a contradictory passed=true flag", async () => {
const f = await fixture('{"passed":true,"issues":["主体细节已改变"],"failedNodeIds":["result"]}');
const result = await verifyCanvasAgentResults(f.params);