fix(agent): pause failed paid steps and preserve repair references

This commit is contained in:
WuYongJie 2026-11-06 09:35:35 +08:00
parent 8bf8e349ca
commit 0ce9a95a9c
6 changed files with 135 additions and 20 deletions

View File

@ -9,6 +9,7 @@ import {
summarizeAgentCapabilities,
} from "./capability-access";
import type { CanvasAgentTool } from "./loop";
import { AgentToolRejectedError } from "./protocol";
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
export function registerImageCanvasAccessTools(params: {
@ -59,10 +60,10 @@ export function registerImageCanvasAccessTools(params: {
(item) => item.workspace === "canvas" && item.id === input.featureId,
);
if (!feature?.authorized || !feature.id.startsWith("canvas.ui."))
throw new Error("请使用清单中的图片画布界面入口,媒体能力请使用对应专用工具");
throw new AgentToolRejectedError("请使用清单中的图片画布界面入口,媒体能力请使用对应专用工具;本次未打开面板或提交处理。");
const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
if (input.nodeIds.some((nodeId) => !nodes.some((node) => node.id === nodeId)))
throw new Error("目标节点不属于当前画布");
throw new AgentToolRejectedError("目标节点不属于当前画布,本次未打开面板或提交处理。");
params.emit("data-canvasAgentUiCommand", {
projectId: params.projectId,
requestId: `${params.runId}:${id}`,

View File

@ -4,7 +4,8 @@ import { join } from "node:path";
import type { NativeEvent, NativeResult } from "@/services/zcode-agent/runner.mjs";
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 { AgentToolRejectedError, publicAgentError, type AgentCallRecord, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion";
import { requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results";
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
@ -37,6 +38,17 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
snapshot.message = message;
await ports.checkpoint(structuredClone(snapshot), status); return status;
}
function failureMessage(call: AgentCallRecord) {
if (call.confirmedNoResult) return `${call.error}\n\n请求编号:${ports.runId}`;
const details = getRequestFailureDetails(call.error || "任务结果尚未确认");
if (call.uncertain || call.status === "started") {
if (details.outcome !== "unknown") details.message += "结果尚未确认。";
details.outcome = "unknown";
details.recommendation = "请联系管理员按请求编号核对原任务结果;不要换模型、新建对话或重新提交,以免重复生成。";
}
if (!details.recommendation) details.recommendation = "请联系管理员并提供请求编号,核对该步骤的错误。";
return `${call.title || "创作工具"}:${formatRequestFailure(details, ports.runId)}`;
}
let modelError = "";
let toolFailure = "";
let nativeFailed = false;
@ -44,11 +56,11 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
const seenTextEvents = new Set<string>();
let lastTextMessage = "";
async function appendText(delta: string) {
delta = publicAssistantText(delta).slice(0, Math.max(0, 100_000 - (snapshot.assistantText?.length || 0)));
if (!delta) return;
snapshot.assistantText = (snapshot.assistantText || "") + delta;
const text = publicAssistantText(delta).slice(0, Math.max(0, 100_000 - (snapshot.assistantText?.length || 0)));
if (!text) return;
snapshot.assistantText = (snapshot.assistantText || "") + text;
await ports.checkpoint(structuredClone(snapshot), "running");
ports.onAssistantText?.(delta);
ports.onAssistantText?.(text);
}
async function finishVerifiedCutout() {
snapshot.nativeRuntime!.state = "stopped";
@ -56,8 +68,8 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
return save(verifiedCompletion, "completed");
}
try {
if (snapshot.calls.some(call => call.status === "started" || call.uncertain))
return await save("存在结果未确认的工具调用,请先核对画布,避免重复生成。", "needs_review");
const unconfirmed = snapshot.calls.find(call => call.status === "started" || call.uncertain);
if (unconfirmed) return await save(failureMessage(unconfirmed), "needs_review");
snapshot.nativeRuntime = { ...snapshot.nativeRuntime, engine: "zcode", state: "starting" };
const schemas = new Map(Object.entries(ports.definitions).map(([name, definition]) => [name, asSchema(definition.inputSchema!)]));
const toolDescriptors = Object.entries(ports.tools).map(([name, entry]) => ({ name,
@ -111,6 +123,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
await save("正在准备处理任务");
}),
onEvent: async (event: NativeEvent | NativeResult) => {
if (attemptController.signal.aborted) return;
if (event.type === "model.streaming" && event.payload?.kind === "text_delta" && typeof event.payload.delta === "string") {
// Child sessions and reasoning/tool-argument deltas are private.
if (snapshot.nativeRuntime?.sessionId && event.sessionId !== snapshot.nativeRuntime.sessionId) return;
@ -155,9 +168,11 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
if (!entry.readOnly && !snapshot.calls.some(call => call.name === "planCanvasTask" && call.status === "succeeded"))
throw new Error("请先制定任务计划再执行画布修改");
if (!entry.readOnly && snapshot.verification?.retryable && snapshot.verification.failedNodeIds?.length) {
const targets = [input.nodeId, ...(Array.isArray(input.nodeIds) ? input.nodeIds : []),
...(Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds : [])].filter((id): id is string => typeof id === "string");
if (!targets.length || targets.some(id => !snapshot.verification!.failedNodeIds!.includes(id)))
const targets = [input.nodeId, ...(Array.isArray(input.nodeIds) ? input.nodeIds : [])]
.filter((id): id is string => typeof id === "string");
const references = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds : [];
const failedIds = snapshot.verification.failedNodeIds;
if (targets.length ? targets.some(id => !failedIds.includes(id)) : !references.some(id => failedIds.includes(String(id))))
throw new Error("修复只能处理未通过验收的节点,禁止重复生成已通过的结果");
}
snapshot.calls.push({ id: callId, name, title: entry.title, input, status: "started" });
@ -205,8 +220,9 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
call.status = "failed"; call.error = publicAgentError(error); call.rejected = error instanceof AgentToolRejectedError;
call.confirmedNoResult = error instanceof AgentImageGenerationError && error.confirmedNoResult;
call.uncertain = !entry.readOnly && !call.rejected && !call.confirmedNoResult;
if (call.confirmedNoResult) toolFailure = call.error;
await save(call.error);
if (call.uncertain || call.confirmedNoResult) toolFailure = failureMessage(call);
await save(toolFailure || call.error);
if (call.uncertain || call.confirmedNoResult) attemptController.abort();
});
throw error;
}
@ -228,15 +244,15 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
if (toolFailure) return await save(toolFailure, "needs_review");
// A failed read is not an uncertain paid operation. Independently verify
// actual outputs below instead of failing a task because an earlier read timed out.
if (snapshot.calls.some(call => (call.status === "failed" && !call.rejected && !call.confirmedNoResult && !ports.tools[call.name]?.readOnly) || call.uncertain))
return await save("部分工具结果待确认,已保留执行记录,请核对后继续。", "needs_review");
const unresolvedRead = snapshot.calls.some((call, index) => call.status === "failed" && !call.rejected && ports.tools[call.name]?.readOnly &&
const failedWrite = snapshot.calls.find(call => (call.status === "failed" && !call.rejected && !call.confirmedNoResult && !ports.tools[call.name]?.readOnly) || call.uncertain);
if (failedWrite) return await save(failureMessage(failedWrite), "needs_review");
const unresolvedRead = snapshot.calls.find((call, index) => call.status === "failed" && !call.rejected && ports.tools[call.name]?.readOnly &&
!snapshot.calls.slice(index + 1).some(later => later.name === call.name && later.status === "succeeded" &&
JSON.stringify(later.input) === JSON.stringify(call.input)));
// Without a real result to independently inspect, a viewport/UI action
// cannot turn a failed analysis into a completed task.
if (unresolvedRead && !snapshot.resultNodeIds.length)
return await save("图片或资料暂未读取成功,已保留记录,请稍后继续核对。", "needs_review");
return await save(failureMessage(unresolvedRead), "needs_review");
if (question || snapshot.calls.some(call => requiresCanvasInteraction(snapshot, call)))
return await save(question || "请在已打开的工具面板中完成操作,然后继续任务。", "needs_review");
const supplements = snapshot.inputs.filter(input => !startedInputs.has(input.id));

View File

@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/routes",
testMatch: /(?:canvas-agent-runtime(?:-store|-review|-video|-image-preferences|-image-failover|-progress)?|canvas-agent-project-tools|agent-capability-access)\.test\.tsx?/,
testMatch: /(?:canvas-agent-runtime(?:-store|-review|-video|-image-preferences|-image-failover|-progress)?|canvas-agent-project-tools|agent-capability-access|zcode-agent-task)\.test\.tsx?/,
fullyParallel: true,
retries: 0,
workers: 2,

View File

@ -50,6 +50,7 @@ export function serverLoader(mocks: Record<string, unknown>, env: Record<string,
setImmediate,
AbortSignal,
AbortController,
structuredClone,
ReadableStream,
Headers,
Blob,

View File

@ -127,7 +127,9 @@ test("Agent cannot open the retired angle panel through a generic UI command", a
const events: unknown[] = [], definitions: any = {}, executable: any = {};
const load = serverLoader({ "@/lib/canvas/agent-mutations": {}, "./canvas": {} });
load("lib/ai/canvas-agent-runtime/image-access-tools.ts").registerImageCanvasAccessTools({ userId: "u", projectId: "p", runId: "r", selectedNodeIds: ["reference"], definitions, executable, emit: (...args: unknown[]) => events.push(args) });
await expect(executable.openCanvasFeature.execute({ featureId: "canvas.ui.imageMultiAngle" }, "old-panel")).rejects.toThrow("图片画布界面入口");
const error = await executable.openCanvasFeature.execute({ featureId: "canvas.ui.imageMultiAngle" }, "old-panel").catch((failure: unknown) => failure);
expect(error).toBeInstanceOf(load("lib/ai/canvas-agent-runtime/protocol.ts").AgentToolRejectedError);
expect(error.message).toContain("图片画布界面入口");
expect(events).toHaveLength(0);
});
test("access manifest covers all configured video functions and node/UI actions without granting fake backends", () => {

View File

@ -0,0 +1,95 @@
import { expect, test } from "@playwright/test";
import { tool } from "ai";
import { z } from "zod";
import type { AgentRunSnapshot } from "@/lib/ai/canvas-agent-runtime/protocol";
import type { runZcodeCanvasTask } from "@/lib/ai/zcode-agent/task";
import { serverLoader } from "../helpers/canvas-download-server-loader";
function fixture() {
const load = serverLoader({ "./native-runtime": {} });
const run: typeof runZcodeCanvasTask = load("lib/ai/zcode-agent/task.ts").runZcodeCanvasTask;
const snapshot: AgentRunSnapshot = load("lib/ai/canvas-agent-runtime/protocol.ts").newAgentRunSnapshot("替换图片中的底座", []);
const ports: Parameters<typeof runZcodeCanvasTask>[0] = {
runId: "original-request", snapshot, instructions: "", context: {},
gateway: { modelId: "test", apiType: "openai-chat-completions", forward: async () => Response.json({}) },
signal: new AbortController().signal,
definitions: {
planCanvasTask: tool({ inputSchema: z.object({}) }),
generateCanvasImages: tool({ inputSchema: z.object({ referenceNodeIds: z.array(z.string()) }) }),
},
tools: {
planCanvasTask: { title: "安排步骤", readOnly: true, concurrentSafe: true, execute: async () => ({ steps: ["替换底座"] }) },
generateCanvasImages: { title: "生成图片", readOnly: false, concurrentSafe: false, execute: async () => ({ createdNodeIds: ["repaired-result"] }) },
},
checkpoint: async () => undefined,
consumeInputs: async () => ({ cancelled: false }), isCancelled: async () => false,
verify: async () => ({ passed: true, issues: [] }),
};
return { ports, run, snapshot };
}
const result = { type: "result", sessionId: "test-session", response: "已完成" };
test("an unconfirmed paid tool stops the planner and immediately preserves the reason and original request ID", async () => {
const { ports, run, snapshot } = fixture();
const emitted: string[] = [], saved: string[] = [];
let submissions = 0;
ports.tools.generateCanvasImages.execute = async () => {
submissions++;
throw Object.assign(new Error("This operation was aborted"), { name: "AbortError" });
};
ports.onAssistantText = text => emitted.push(text);
ports.checkpoint = async state => { saved.push(state.message); };
ports.native = async options => {
await options.execute("planCanvasTask", {}, "plan", options.signal!);
await expect(options.execute("generateCanvasImages", { referenceNodeIds: ["original"] }, "unknown-result", options.signal!)).rejects.toThrow();
expect(options.signal!.aborted).toBe(true);
expect(saved.at(-1)).toContain("请求编号:original-request");
await options.onEvent?.({ type: "model.streaming", sessionId: "test-session", payload: { kind: "text_delta", assistantMessageId: "after-error", delta: "新建对话绕过原任务后再试" } });
await expect(options.execute("generateCanvasImages", { referenceNodeIds: ["original"] }, "duplicate-paid", options.signal!)).rejects.toThrow();
throw new Error("planner stopped");
};
expect(await run(ports)).toBe("needs_review");
expect(submissions).toBe(1);
expect(emitted).toEqual([]);
expect(snapshot.message).toContain("结果尚未确认");
expect(snapshot.message).toContain("不要换模型、新建对话或重新提交");
expect(snapshot.message).toContain(ports.runId);
ports.native = async () => { throw new Error("must not start a new attempt"); };
expect(await run(ports)).toBe("needs_review");
expect(submissions).toBe(1);
});
test("repairing a failed result permits original images as references without admitting another passed target", async () => {
const { ports, run, snapshot } = fixture();
let submissions = 0;
snapshot.verification = { passed: false, issues: ["底座不符"], retryable: true, failedNodeIds: ["failed-result"] };
ports.tools.generateCanvasImages.execute = async input => {
submissions++;
expect(input.referenceNodeIds).toEqual(["failed-result", "original-scene", "original-product"]);
return { createdNodeIds: ["repaired-result"] };
};
ports.native = async options => {
await options.execute("planCanvasTask", {}, "plan", options.signal!);
await expect(options.execute("generateCanvasImages", { referenceNodeIds: ["original-scene", "passed-result"] }, "wrong-target", options.signal!)).rejects.toThrow("未通过");
await options.execute("generateCanvasImages", { referenceNodeIds: ["failed-result", "original-scene", "original-product"] }, "repair-failed", options.signal!);
return result;
};
expect(await run(ports)).toBe("completed");
expect(submissions).toBe(1);
});
test("an unresolved image read reports the concrete input failure and recommendation", async () => {
const { ports, run, snapshot } = fixture();
ports.definitions.readCanvas = tool({ inputSchema: z.object({}) });
ports.tools.readCanvas = { title: "读取图片", readOnly: true, concurrentSafe: true, execute: async () => { throw new Error("无法获取图片,请检查图片链接是否有效:fetch failed"); } };
ports.native = async options => {
await expect(options.execute("readCanvas", {}, "failed-read", options.signal!)).rejects.toThrow();
expect(options.signal!.aborted).toBe(false);
return result;
};
expect(await run(ports)).toBe("needs_review");
expect(snapshot.message).toContain("图片无法读取");
expect(snapshot.message).toContain("重新上传原图");
expect(snapshot.message).toContain(ports.runId);
});