fix(canvas-agent): cover legacy tools and recover confirmed request rejections
This commit is contained in:
parent
b619005c07
commit
cea172a327
@ -140,7 +140,7 @@ export function CanvasAgentRunPanel(props: {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">
|
||||
{run.runtime?.engine === "zcode" ? <span className="mr-2">ZCode · {run.runtime.state === "starting" ? "正在启动独立沙箱" : "独立沙箱"}</span> : null}
|
||||
{run.runtime?.engine === "zcode" ? <span className="mr-2">ZCode · {!run.runtime.sandbox ? "尚未启动沙箱" : run.runtime.state === "starting" ? "正在启动独立沙箱" : run.runtime.state === "running" ? "独立沙箱运行中" : "沙箱已停止"}</span> : null}
|
||||
已处理 {run.steps} 轮 · {run.toolCount} 个工具步骤
|
||||
</div>
|
||||
{run.inputs.slice(-2).map((input) => (
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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 = [
|
||||
|
||||
10
lib/ai/canvas-agent-runtime/legacy-tool-map.ts
Normal file
10
lib/ai/canvas-agent-runtime/legacy-tool-map.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/** Legacy planner names map to actual native MCP operations, not another planner loop. */
|
||||
export const LEGACY_AGENT_TOOL_MAP: Record<string, string> = {
|
||||
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",
|
||||
};
|
||||
@ -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: "请按用户当前任务采用技能指令,并使用真实已授权工具执行;不得虚构结果。" };
|
||||
});
|
||||
|
||||
@ -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<string, unknown> };
|
||||
export type AgentCallRecord = AgentCall & {
|
||||
status: "queued" | "started" | "succeeded" | "failed";
|
||||
uncertain?: boolean;
|
||||
rejected?: boolean;
|
||||
output?: Record<string, unknown>;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@ -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<CanvasAgentRunSt
|
||||
} catch (error) {
|
||||
await exclusive(async () => {
|
||||
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<CanvasAgentRunSt
|
||||
await ports.consumeInputs(snapshot);
|
||||
});
|
||||
if (await ports.isCancelled()) return await save("已停止后续处理,完成的结果保留在画布。", "cancelled");
|
||||
if (snapshot.calls.some(call => 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++;
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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<typeof runNativeZcode>[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 });
|
||||
|
||||
}
|
||||
|
||||
@ -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) }]),
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user