fix(agent): pause failed paid steps and preserve repair references
This commit is contained in:
parent
8bf8e349ca
commit
0ce9a95a9c
@ -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}`,
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -50,6 +50,7 @@ export function serverLoader(mocks: Record<string, unknown>, env: Record<string,
|
||||
setImmediate,
|
||||
AbortSignal,
|
||||
AbortController,
|
||||
structuredClone,
|
||||
ReadableStream,
|
||||
Headers,
|
||||
Blob,
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
95
tests/routes/zcode-agent-task.test.ts
Normal file
95
tests/routes/zcode-agent-task.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user