From cea172a327cd3bc821731975d8a42717295f8077 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 20 Oct 2026 17:32:31 +0800 Subject: [PATCH] fix(canvas-agent): cover legacy tools and recover confirmed request rejections --- .../chat-panel/canvas-agent-run-panel.tsx | 2 +- .../zcode-canvas/authorization-audit.json | 28 +++++++++++++++++++ .../canvas-agent-runtime/capability-access.ts | 2 +- .../canvas-agent-runtime/legacy-tool-map.ts | 10 +++++++ lib/ai/canvas-agent-runtime/project-tools.ts | 5 +++- lib/ai/canvas-agent-runtime/protocol.ts | 3 ++ lib/ai/zcode-agent/task.ts | 16 ++++++----- scripts/zcode-agent/audit-project-tools.mjs | 5 +++- scripts/zcode-agent/task-fixture.ts | 10 ++++++- tests/routes/agent-capability-access.test.ts | 2 +- .../routes/canvas-agent-project-tools.test.ts | 8 ++++++ 11 files changed, 78 insertions(+), 13 deletions(-) create mode 100644 lib/ai/canvas-agent-runtime/legacy-tool-map.ts diff --git a/components/canvas/chat-panel/canvas-agent-run-panel.tsx b/components/canvas/chat-panel/canvas-agent-run-panel.tsx index 04c5727e9..a25046c5b 100644 --- a/components/canvas/chat-panel/canvas-agent-run-panel.tsx +++ b/components/canvas/chat-panel/canvas-agent-run-panel.tsx @@ -140,7 +140,7 @@ export function CanvasAgentRunPanel(props: { ) : null}
- {run.runtime?.engine === "zcode" ? ZCode · {run.runtime.state === "starting" ? "正在启动独立沙箱" : "独立沙箱"} : null} + {run.runtime?.engine === "zcode" ? ZCode · {!run.runtime.sandbox ? "尚未启动沙箱" : run.runtime.state === "starting" ? "正在启动独立沙箱" : run.runtime.state === "running" ? "独立沙箱运行中" : "沙箱已停止"} : null} 已处理 {run.steps} 轮 · {run.toolCount} 个工具步骤
{run.inputs.slice(-2).map((input) => ( diff --git a/docs/specs/zcode-canvas/authorization-audit.json b/docs/specs/zcode-canvas/authorization-audit.json index 22aadd968..fe1009f2a 100644 --- a/docs/specs/zcode-canvas/authorization-audit.json +++ b/docs/specs/zcode-canvas/authorization-audit.json @@ -1,4 +1,32 @@ { + "legacyAliases": { + "getWeather": "getWeather", + "createDocument": "createDocument", + "updateDocument": "updateDocument", + "requestSuggestions": "requestSuggestions", + "generateVideo": "paused:video-authorization", + "respondDirect": "native-final-response", + "resolveTargetImage": "readCanvas", + "analyzeTargetImage": "analyzeCanvasImages", + "analyzeImage": "analyzeCanvasImages", + "generateNewImage": "generateCanvasImages", + "generateImage": "generateCanvasImages", + "editTargetImage": "editTargetImage", + "editImage": "editTargetImage", + "publishResultToCanvas": "editCanvasNodes", + "publishToCanvas": "editCanvasNodes", + "removeBackground": "removeBackground", + "removeWatermark": "removeWatermark", + "enhanceImage": "enhanceImage", + "extendImage": "extendImage", + "resizeImage": "resizeImage", + "angleEditImage": "angleEditImage", + "localEditImage": "localEditImage", + "boxCutout": "boxCutout", + "useSelectedCanvasImage": "readCanvas", + "useLatestResultImage": "readCanvas", + "useOriginalUploadImage": "readCanvas" + }, "policy": "图片画布及非视频项目业务已授权;视频画布与分镜暂不授权;底层用户权限与服务开关仍生效。管理、密钥、支付、身份认证、系统任务和Claw不属于创作Agent工具。", "upstreamCommit": "0a1fc56d279fd1001f78387bd81aec1c26767411", "rows": [ diff --git a/lib/ai/canvas-agent-runtime/capability-access.ts b/lib/ai/canvas-agent-runtime/capability-access.ts index 03f467e79..504d6026c 100644 --- a/lib/ai/canvas-agent-runtime/capability-access.ts +++ b/lib/ai/canvas-agent-runtime/capability-access.ts @@ -26,7 +26,7 @@ export function summarizeAgentCapabilities(items: AgentCapabilityAccess[]) { authorized, execution, ...(authorized && execution === "server" ? { entry } : {}), - ...(execution === "backend_required" || !authorized ? { note } : {}), + ...(!authorized ? { note: "暂未授权" } : execution === "backend_required" ? { note } : {}), })); } export const VIDEO_CANVAS_EXTRA_ACTIONS = [ diff --git a/lib/ai/canvas-agent-runtime/legacy-tool-map.ts b/lib/ai/canvas-agent-runtime/legacy-tool-map.ts new file mode 100644 index 000000000..98d8bd275 --- /dev/null +++ b/lib/ai/canvas-agent-runtime/legacy-tool-map.ts @@ -0,0 +1,10 @@ +/** Legacy planner names map to actual native MCP operations, not another planner loop. */ +export const LEGACY_AGENT_TOOL_MAP: Record = { + getWeather:"getWeather", createDocument:"createDocument", updateDocument:"updateDocument", requestSuggestions:"requestSuggestions", + generateVideo:"paused:video-authorization", respondDirect:"native-final-response", resolveTargetImage:"readCanvas", + analyzeTargetImage:"analyzeCanvasImages", analyzeImage:"analyzeCanvasImages", generateNewImage:"generateCanvasImages", generateImage:"generateCanvasImages", + editTargetImage:"editTargetImage", editImage:"editTargetImage", publishResultToCanvas:"editCanvasNodes", publishToCanvas:"editCanvasNodes", + removeBackground:"removeBackground", removeWatermark:"removeWatermark", enhanceImage:"enhanceImage", extendImage:"extendImage", resizeImage:"resizeImage", + angleEditImage:"angleEditImage", localEditImage:"localEditImage", boxCutout:"boxCutout", useSelectedCanvasImage:"readCanvas", + useLatestResultImage:"readCanvas", useOriginalUploadImage:"readCanvas", +}; diff --git a/lib/ai/canvas-agent-runtime/project-tools.ts b/lib/ai/canvas-agent-runtime/project-tools.ts index 61291eaa6..dac978264 100644 --- a/lib/ai/canvas-agent-runtime/project-tools.ts +++ b/lib/ai/canvas-agent-runtime/project-tools.ts @@ -1,7 +1,9 @@ import "server-only"; +import { AgentToolRejectedError } from "./protocol"; import { tool, type ToolSet } from "ai"; import type { Session } from "next-auth"; import { z } from "zod"; +import { LEGACY_AGENT_TOOL_MAP } from "./legacy-tool-map"; import contracts from "./project-operation-contracts.json"; import { getAccessibleAgentPresetById, getDocumentById } from "@/lib/db/queries"; import { getEnabledTools } from "@/lib/ai/tools-registry"; @@ -57,6 +59,7 @@ export async function registerProjectAgentTools(params: { if (!response.headers.get("content-type")?.includes("application/json")) { await response.body?.cancel(); throw new Error(`此工具未返回可核验JSON结果(${response.status});请使用真实界面处理文件或流式操作`); } const raw = await response.text(); if (raw.length > 2 * 1024 * 1024) throw new Error("业务返回过大,请缩小查询范围"); const payload = JSON.parse(raw); + if ([400,401,403,404,405,422].includes(response.status)) throw new AgentToolRejectedError(`业务工具参数或权限未通过(${response.status}),未提交操作。${typeof payload?.error === "string" ? payload.error.slice(0,200) : "请先读取该操作的真实请求契约"}`); if (!response.ok || payload?.success === false || payload?.error) throw new Error(`业务工具 ${operation.title} 未成功(${response.status}):${typeof payload?.error === "string" ? payload.error.slice(0, 200) : "请核对输入参数或底层服务权限"}`); if (operation.id === "canvas-list.get" && Array.isArray(payload?.data)) payload.data = payload.data.filter((row: { workspaceType?: string }) => row.workspaceType !== "video"); const status = payload?.processing || ["pending", "running", "queued"].includes(payload?.status) ? "pending" : payload?.status === "failed" ? "failed" : "succeeded"; @@ -69,7 +72,7 @@ export async function registerProjectAgentTools(params: { const preset = await getAccessibleAgentPresetById({ id: String(input.presetId), userId }); if (!preset || preset.status !== "active") throw new Error("此技能不可用或无权访问"); if (preset.type === "video") throw new Error("视频技能暂未授权给 ZCode Agent"); - return { id: preset.id, name: preset.name, type: preset.type, promptTemplate: preset.promptTemplate, defaultModelId: preset.defaultModelId, + return { toolAliases: LEGACY_AGENT_TOOL_MAP, id: preset.id, name: preset.name, type: preset.type, promptTemplate: preset.promptTemplate, defaultModelId: preset.defaultModelId, defaultParams: sanitizeProjectToolOutput(preset.defaultParams), slots: preset.slots.map(slot => ({ title: slot.title, promptTemplate: slot.promptTemplate, defaultParams: sanitizeProjectToolOutput(slot.defaultParams) })), message: "请按用户当前任务采用技能指令,并使用真实已授权工具执行;不得虚构结果。" }; }); diff --git a/lib/ai/canvas-agent-runtime/protocol.ts b/lib/ai/canvas-agent-runtime/protocol.ts index 03fa46a88..3fd7f6723 100644 --- a/lib/ai/canvas-agent-runtime/protocol.ts +++ b/lib/ai/canvas-agent-runtime/protocol.ts @@ -7,10 +7,13 @@ export type CanvasAgentRunStatus = | "needs_review" | "interrupted"; export class AgentRunControlChangedError extends Error {} +/** Server confirms validation/auth rejection before a business operation starts. */ +export class AgentToolRejectedError extends Error {} export type AgentCall = { id: string; name: string; input: Record }; export type AgentCallRecord = AgentCall & { status: "queued" | "started" | "succeeded" | "failed"; uncertain?: boolean; + rejected?: boolean; output?: Record; error?: string; }; diff --git a/lib/ai/zcode-agent/task.ts b/lib/ai/zcode-agent/task.ts index 9d536ac32..77ae24b63 100644 --- a/lib/ai/zcode-agent/task.ts +++ b/lib/ai/zcode-agent/task.ts @@ -3,7 +3,7 @@ import { asSchema, type ToolSet } from "ai"; import { join } from "node:path"; import { runNativeZcode, type NativeEvent, type NativeResult } from "@/services/zcode-agent/runner.mjs"; import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop"; -import { publicAgentError, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol"; +import { AgentToolRejectedError, publicAgentError, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol"; type Ports = { native?: typeof runNativeZcode; @@ -139,7 +139,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise { const call = snapshot.calls.find(call => call.id === callId)!; - call.status = "failed"; call.error = publicAgentError(error); call.uncertain = !entry.readOnly; + call.status = "failed"; call.error = publicAgentError(error); call.rejected = error instanceof AgentToolRejectedError; call.uncertain = !entry.readOnly && !call.rejected; await save(call.error); }); throw error; @@ -155,28 +155,30 @@ export async function runZcodeCanvasTask(ports: Ports): Promise call.status === "failed" || call.uncertain)) + if (snapshot.calls.some(call => (call.status === "failed" && !call.rejected) || call.uncertain)) return await save("部分工具结果待确认,已保留执行记录,请核对后继续。", "needs_review"); if (question || snapshot.calls.some(call => call.output?.mode === "interactive")) 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; } - const projectWrites = snapshot.calls.filter(call => !ports.tools[call.name]?.readOnly && call.output?.projectService); - const canvasWrites = snapshot.calls.filter(call => !ports.tools[call.name]?.readOnly && !call.output?.projectService); + const projectWrites = snapshot.calls.filter(call => !call.rejected && !ports.tools[call.name]?.readOnly && call.output?.projectService); + const canvasWrites = snapshot.calls.filter(call => !call.rejected && !ports.tools[call.name]?.readOnly && !call.output?.projectService); if (projectWrites.length && !canvasWrites.length && !snapshot.requirements?.expectedImageCount) { const pending = projectWrites.some(call => call.output?.status !== "succeeded"); return await save(pending ? "业务任务已受理或等待核对,请读取实际任务状态,保留原任务编号继续。" : result.response || "项目业务操作已完成。", pending ? "needs_review" : "completed"); } // Text/analysis tasks have no generated media to inspect. Mutating tasks // still use the existing independent checks against actual saved nodes. - const mutated = snapshot.calls.some(call => !ports.tools[call.name]?.readOnly); + const mutated = snapshot.calls.some(call => !call.rejected && !ports.tools[call.name]?.readOnly); if (!mutated && !snapshot.requirements?.expectedImageCount) { const expectsChange = /生成|抠图|去背景|换背景|删除|移除|替换|改名|移动|放大|裁剪|旋转|导出|下载|制作|添加|上传/.test(snapshot.objective) && !/只(?:要|需)?(?:分析|讨论|建议|提示词)|不要(?:执行|生成|修改)/.test(snapshot.objective); return await save(expectsChange ? "尚未执行画布操作,请核对任务计划后继续。" : result.response || "已完成画布分析。", expectsChange ? "needs_review" : "completed"); } await save("正在核对画布中的实际结果"); - const verification = await ports.verify(snapshot); snapshot.verification = verification; + const reviewSnapshot = { ...snapshot, calls: snapshot.calls.filter(call => !call.rejected) }; + const verification = await ports.verify(reviewSnapshot); snapshot.verification = verification; + snapshot.inputTokens = reviewSnapshot.inputTokens; snapshot.outputTokens = reviewSnapshot.outputTokens; if (verification.passed) return await save(result.response || "任务完成,结果已写入画布。", "completed"); if (verification.retryable && snapshot.repairs < 2) { snapshot.repairs++; diff --git a/scripts/zcode-agent/audit-project-tools.mjs b/scripts/zcode-agent/audit-project-tools.mjs index 434072259..28abc4ea4 100644 --- a/scripts/zcode-agent/audit-project-tools.mjs +++ b/scripts/zcode-agent/audit-project-tools.mjs @@ -31,7 +31,10 @@ console.log(JSON.stringify({ operations: catalog.length, routeContracts: Object. const canvas = JSON.parse(execFileSync(process.execPath, ['node_modules/tsx/dist/cli.mjs','-e', "import {getCanvasAgentAccessManifest} from './lib/ai/canvas-agent-runtime/capability-access';console.log(JSON.stringify(getCanvasAgentAccessManifest()))"], { encoding: 'utf8' })); const project = catalog.map(item=>({...item, authorized:item.group !== '分镜任务', previouslyExposed:false, execution:'server'})); const utilities = ['getWeather','createDocument','updateDocument','requestSuggestions','loadProjectSkill'].map(id=>({id,title:id,group:'文档、天气与技能',authorized:true,previouslyExposed:false,entry:id,execution:'server'})); -const report = { policy:'图片画布及非视频项目业务已授权;视频画布与分镜暂不授权;底层用户权限与服务开关仍生效。管理、密钥、支付、身份认证、系统任务和Claw不属于创作Agent工具。', +const legacyAliases = JSON.parse(execFileSync(process.execPath, ['node_modules/tsx/dist/cli.mjs','-e', "import {LEGACY_AGENT_TOOL_MAP} from './lib/ai/canvas-agent-runtime/legacy-tool-map';console.log(JSON.stringify(LEGACY_AGENT_TOOL_MAP))"], { encoding: 'utf8' })); +const legacyNames = [...(await readFile('lib/ai/tools-registry.ts','utf8')).matchAll(/name:\s*"([^"]+)"/g)].map(match=>match[1]); +if (legacyNames.some(name=>!legacyAliases[name])) throw new Error('Legacy tool missing from authorization audit'); +const report = { legacyAliases, policy:'图片画布及非视频项目业务已授权;视频画布与分镜暂不授权;底层用户权限与服务开关仍生效。管理、密钥、支付、身份认证、系统任务和Claw不属于创作Agent工具。', upstreamCommit:'0a1fc56d279fd1001f78387bd81aec1c26767411', rows:[...canvas,...project,...utilities], verifiedRoutes:Object.keys(contracts).length, deploymentOnly:['smart-edit.get'] }; await writeFile('docs/specs/zcode-canvas/authorization-audit.json',JSON.stringify(report,null,2)+'\n'); diff --git a/scripts/zcode-agent/task-fixture.ts b/scripts/zcode-agent/task-fixture.ts index 5a45380ac..968e58606 100644 --- a/scripts/zcode-agent/task-fixture.ts +++ b/scripts/zcode-agent/task-fixture.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { tool } from 'ai'; import { z } from 'zod'; import { runZcodeCanvasTask } from '../../lib/ai/zcode-agent/task'; -import { newAgentRunSnapshot } from '../../lib/ai/canvas-agent-runtime/protocol'; +import { AgentToolRejectedError, newAgentRunSnapshot } from '../../lib/ai/canvas-agent-runtime/protocol'; import type { runNativeZcode } from '../../services/zcode-agent/runner.mjs'; type NativeOptions = Parameters[0]; function fixture(objective = '修改图片') { @@ -64,6 +64,14 @@ let checks = 0; const h = fixture(); h.ports.isCancelled = async () => true; assert.equal(await runZcodeCanvasTask(h.ports), 'cancelled'); assert.equal(h.writes(), 1); checks++; } +{ + const h = fixture(); let attempts = 0; + h.ports.tools.edit.execute = async () => { attempts++; if (attempts === 1) throw new AgentToolRejectedError('参数未通过,未提交'); return { updatedNodeIds: ['a'] }; }; + h.ports.native = async options => { await options.execute('planCanvasTask', {}, 'plan', options.signal!); + await assert.rejects(options.execute('edit', { nodeIds: ['a'] }, 'rejected', options.signal!)); + await options.execute('edit', { nodeIds: ['a'] }, 'corrected', options.signal!); return result; }; + assert.equal(await runZcodeCanvasTask(h.ports), 'completed'); assert.equal(h.snapshot.calls.find(c=>c.id==='rejected')?.uncertain,false); checks++; +} console.log('NATIVE_TASK_ADAPTER_PASSED', { checks }); } diff --git a/tests/routes/agent-capability-access.test.ts b/tests/routes/agent-capability-access.test.ts index d32dcbac3..0c152f115 100644 --- a/tests/routes/agent-capability-access.test.ts +++ b/tests/routes/agent-capability-access.test.ts @@ -27,7 +27,7 @@ test("full capability discovery leaves room for subsequent edits without losing const manifest = getCanvasAgentAccessManifest(); const summaries = summarizeAgentCapabilities(manifest); expect(summaries.map((item) => item.id)).toEqual(manifest.map((item) => item.id)); - for (const item of manifest.filter((entry) => entry.execution === "backend_required")) + for (const item of manifest.filter((entry) => entry.execution === "backend_required" && entry.authorized)) expect(summaries.find((entry) => entry.id === item.id)?.note).toBe(item.note); expect( estimateContextTokens([{ role: "user", content: JSON.stringify(summaries) }]), diff --git a/tests/routes/canvas-agent-project-tools.test.ts b/tests/routes/canvas-agent-project-tools.test.ts index 6eed25f4f..d4264c3d1 100644 --- a/tests/routes/canvas-agent-project-tools.test.ts +++ b/tests/routes/canvas-agent-project-tools.test.ts @@ -1,3 +1,4 @@ +import { LEGACY_AGENT_TOOL_MAP } from "@/lib/ai/canvas-agent-runtime/legacy-tool-map"; import { expect, test } from "@playwright/test"; import { readFileSync, existsSync } from "node:fs"; import { PROJECT_AGENT_OPERATIONS, projectOperationAuthorized, projectOperationUrl, sanitizeProjectToolOutput } from "@/lib/ai/canvas-agent-runtime/project-operation-catalog"; @@ -30,3 +31,10 @@ test("operation URL substitution rejects path escapes and keeps query values enc test("nested model/storage secrets cannot be returned to the native model", () => { expect(sanitizeProjectToolOutput({ data:[{ id:'m',apiKey:'secret', provider:{ SK:'hidden',AK:'hidden',secretAccessKey:'hidden',baseUrl:'https://safe' } }], headers:{ authorization:'secret' } })).toEqual({data:[{id:'m',provider:{baseUrl:'https://safe'}}]}); }); + +test("every legacy planner tool has a concrete native counterpart or explicit video pause", async () => { + const names=[...readFileSync('lib/ai/tools-registry.ts','utf8').matchAll(/name:\s*"([^"]+)"/g)].map(match=>match[1]); + expect(names).toHaveLength(26); + for (const name of names) expect(LEGACY_AGENT_TOOL_MAP[name],name).toBeTruthy(); + expect(LEGACY_AGENT_TOOL_MAP.generateVideo).toBe('paused:video-authorization'); +});