diff --git a/lib/ai/canvas-agent-runtime/results.ts b/lib/ai/canvas-agent-runtime/results.ts index 2c1a30b41..a3a2ca98c 100644 --- a/lib/ai/canvas-agent-runtime/results.ts +++ b/lib/ai/canvas-agent-runtime/results.ts @@ -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, diff --git a/lib/ai/canvas-agent-runtime/review.ts b/lib/ai/canvas-agent-runtime/review.ts index 5cfb55bc8..d2503b640 100644 --- a/lib/ai/canvas-agent-runtime/review.ts +++ b/lib/ai/canvas-agent-runtime/review.ts @@ -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: ["部分操作仍在等待交互或未完成"] }; diff --git a/lib/ai/zcode-agent/task.ts b/lib/ai/zcode-agent/task.ts index c79c563c6..2f7ffaf77 100644 --- a/lib/ai/zcode-agent/task.ts +++ b/lib/ai/zcode-agent/task.ts @@ -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 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; } diff --git a/scripts/zcode-agent/task-fixture.ts b/scripts/zcode-agent/task-fixture.ts index b34e6d851..79e630891 100644 --- a/scripts/zcode-agent/task-fixture.ts +++ b/scripts/zcode-agent/task-fixture.ts @@ -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!); diff --git a/tests/routes/canvas-agent-runtime-review.test.ts b/tests/routes/canvas-agent-runtime-review.test.ts index f1f35bf03..05358f860 100644 --- a/tests/routes/canvas-agent-runtime-review.test.ts +++ b/tests/routes/canvas-agent-runtime-review.test.ts @@ -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);