fix(canvas): 云端 Agent - 参数错误有限重试并折叠中间失败
This commit is contained in:
@@ -14,6 +14,7 @@ import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-refer
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import { buildSkillMentionReferences } from "@/services/skill-runtime";
|
||||
import { agentToolCategory, agentToolCategoryLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation";
|
||||
import { agentToolRetry, type AgentToolRetryAttempt } from "@/lib/canvas/agent-tool-retry";
|
||||
|
||||
export type CloudAgentChatAttachment = { id: string; name: string; url: string };
|
||||
type CloudAgentOperationImpact = {
|
||||
@@ -325,6 +326,21 @@ export function AgentToolCard({ title, text, detail, theme, references = [], onF
|
||||
const isPlain = !actions.length && !state.isError;
|
||||
const conciseError = text.length > 180 ? `${text.slice(0, 180)}…` : text;
|
||||
const categoryIcon = category === "read" ? <Eye className="size-3.5" /> : category === "create" ? <Plus className="size-3.5" /> : <Pencil className="size-3.5" />;
|
||||
const retry = agentToolRetry(detail);
|
||||
const attempts = objectField(detail, "retryAttempts");
|
||||
if (retry && Array.isArray(attempts)) {
|
||||
const label = retry.status === "recovered" ? "自动纠正后已恢复" : retry.status === "exhausted" ? "自动纠正未完成" : "自动纠正记录";
|
||||
return (
|
||||
<details data-agent-tool-retry className="min-w-0 flex-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
<summary className="cursor-pointer rounded-sm focus-visible:outline focus-visible:outline-2" style={{ outlineColor: theme.node.muted }}>
|
||||
{label} · {retry.attempt}/{retry.maxAttempts} 次尝试未通过
|
||||
</summary>
|
||||
<ol className="mt-2 space-y-1 pl-4" aria-label="自动纠正详情">
|
||||
{(attempts as AgentToolRetryAttempt[]).map((attempt, index) => <li key={attempt.id} className="whitespace-pre-wrap break-words">第 {index + 1} 次:{attempt.text}</li>)}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-agent-tool-card className={`agent-tool-row agent-tool-row--${category}${isPlain ? " agent-tool-row--plain" : ""} flex min-w-0 flex-1 items-start gap-2.5 text-left`} style={{ color: theme.node.text }}>
|
||||
<span className="agent-tool-status shrink-0" style={{ color: state.color }} aria-hidden="true">{state.icon}</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { ArrowLeft, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, MoveDiagonal2, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react";
|
||||
import { saveAs } from "file-saver";
|
||||
import { buildAgentDebugExport } from "@/lib/canvas/agent-debug-export";
|
||||
import { agentToolRetry, mergeAgentToolRetry } from "@/lib/canvas/agent-tool-retry";
|
||||
import { agentPlanVisible, latestAgentPlanItems, pendingAgentQuestion } from "@/lib/canvas/cloud-agent-plan";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
@@ -1236,6 +1237,11 @@ function applyAgentEvent(event: AgentEvent, setMessages: Dispatch<SetStateAction
|
||||
setMessages((current) => appendUniqueMessage(current, { id, role: "tool", title: "canvas_apply_ops", text: "画布操作已完成", detail: { ...detail, eventType: event.type } }));
|
||||
return;
|
||||
}
|
||||
if (event.type.startsWith("tool_") && agentToolRetry(payload)) {
|
||||
const message: CloudAgentChatMessage = { id: event.eventId, role: "tool", title: String(payload.toolName || "工具执行"), text, detail: { ...payload, eventType: event.type } };
|
||||
setMessages((current) => mergeAgentToolRetry(current, message));
|
||||
if (event.type === "tool_failed") return;
|
||||
}
|
||||
if (event.type === "tool_completed" && payload.toolName === "canvas_apply_ops" && payload.callId) {
|
||||
const id = `canvas-${event.runId}-${payload.callId}`;
|
||||
setMessages((current) => appendUniqueMessage(current, { id, role: "tool", title: "canvas_apply_ops", text: text || "画布操作已完成", detail: { ...payload, eventType: event.type } }));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { agentToolRetry } from "./agent-tool-retry";
|
||||
|
||||
export const AGENT_TOOL_METADATA: Record<string, { summary: string | ((context: { pending: boolean; detail?: unknown }) => string); failureMessage: string }> = {
|
||||
canvas_list_node_types: { summary: "已读取可用节点类型", failureMessage: "获取可用节点类型失败" },
|
||||
canvas_get_state: { summary: "已读取当前画布", failureMessage: "获取画布内容失败" },
|
||||
@@ -43,7 +45,7 @@ export function agentToolCategoryLabel(toolName: string, category: AgentToolCate
|
||||
return "操作画布";
|
||||
}
|
||||
|
||||
type ToolStatus = "completed" | "failed" | "noop" | "rejected" | "pending";
|
||||
type ToolStatus = "completed" | "failed" | "noop" | "rejected" | "pending" | "retrying";
|
||||
|
||||
function field(value: unknown, key: string): unknown {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
||||
@@ -51,6 +53,7 @@ function field(value: unknown, key: string): unknown {
|
||||
|
||||
export function agentToolStatus(title: string, text: string, detail?: unknown): ToolStatus {
|
||||
const event = field(detail, "eventType");
|
||||
if (event === "tool_failed" && agentToolRetry(detail)?.status === "retrying") return "retrying";
|
||||
if (event === "tool_completed" || event === "generation_task_created" || event === "canvas_updated") return "completed";
|
||||
if (event === "tool_failed") return "failed";
|
||||
const raw = `${title} ${text} ${field(detail, "error") || ""}`;
|
||||
|
||||
35
web/src/lib/canvas/agent-tool-retry.ts
Normal file
35
web/src/lib/canvas/agent-tool-retry.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
type RetryStatus = "retrying" | "recovered" | "exhausted";
|
||||
export type AgentToolRetry = { groupId: string; attempt: number; maxAttempts: number; status: RetryStatus };
|
||||
type RetryMessage = { id: string; role: string; title?: string; text: string; detail?: unknown };
|
||||
export type AgentToolRetryAttempt = { id: string; text: string; detail: unknown };
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
export function agentToolRetry(detail: unknown): AgentToolRetry | undefined {
|
||||
const retry = record(record(detail).retry);
|
||||
if (typeof retry.groupId !== "string" || !retry.groupId || typeof retry.attempt !== "number" || !Number.isInteger(retry.attempt) || retry.attempt < 1 || typeof retry.maxAttempts !== "number" || !Number.isInteger(retry.maxAttempts) || retry.maxAttempts < retry.attempt) return;
|
||||
if (retry.status !== "retrying" && retry.status !== "recovered" && retry.status !== "exhausted") return;
|
||||
return retry as AgentToolRetry;
|
||||
}
|
||||
|
||||
// Run-scoped group IDs and event IDs keep SSE replay idempotent. Successful
|
||||
// calls retain their ordinary result card; only failed attempts are folded.
|
||||
export function mergeAgentToolRetry<T extends RetryMessage>(messages: T[], message: T): T[] {
|
||||
const retry = agentToolRetry(message.detail);
|
||||
if (!retry) return messages;
|
||||
const id = `tool-retry:${retry.groupId}`;
|
||||
const index = messages.findIndex((item) => item.id === id);
|
||||
const previous = index >= 0 ? messages[index] : undefined;
|
||||
const previousDetail = record(previous?.detail);
|
||||
const attempts: AgentToolRetryAttempt[] = Array.isArray(previousDetail.retryAttempts) ? [...previousDetail.retryAttempts] : [];
|
||||
if (retry.status !== "recovered" && !attempts.some((item) => item.id === message.id)) attempts.push({ id: message.id, text: message.text, detail: message.detail });
|
||||
const previousRetry = agentToolRetry(previous?.detail);
|
||||
// An old failed event must not undo a later recovery during reconnection.
|
||||
const previousIsFinal = previousRetry?.status === "recovered" || previousRetry?.status === "exhausted";
|
||||
const latest = previousRetry && (previousIsFinal || previousRetry.attempt > retry.attempt) ? previousRetry : retry;
|
||||
const group = { ...message, id, detail: { ...record(message.detail), retry: latest, retryAttempts: attempts } };
|
||||
if (index < 0) return [...messages, group];
|
||||
return messages.map((item, itemIndex) => itemIndex === index ? group : item);
|
||||
}
|
||||
@@ -3,6 +3,21 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { AgentToolCard } from "@/components/canvas/canvas-cloud-agent-chat-ui";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
|
||||
test("automatic correction is neutral and collapsed, with recovery and exhaustion history", () => {
|
||||
for (const theme of [canvasThemes.dark, canvasThemes.light]) {
|
||||
for (const status of ["retrying", "recovered", "exhausted"]) {
|
||||
const html = renderToStaticMarkup(<AgentToolCard title="canvas_apply_ops" text="缺少操作类型" detail={{ eventType: "tool_failed", retry: { groupId: "run:repair:call", attempt: 1, maxAttempts: 3, status }, retryAttempts: [{ id: "call", text: "缺少操作类型" }] }} theme={theme} />);
|
||||
expect(html).toContain("data-agent-tool-retry");
|
||||
expect(html).toContain("<summary");
|
||||
expect(html).not.toContain("open=");
|
||||
expect(html).not.toContain("#dc2626");
|
||||
expect(html).not.toContain("执行失败");
|
||||
expect(html).toContain("缺少操作类型");
|
||||
expect(html).toContain(status === "recovered" ? "自动纠正后已恢复" : status === "exhausted" ? "自动纠正未完成" : "自动纠正记录");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("completed tools keep an accessible status without a duplicate visual badge", () => {
|
||||
const html = renderToStaticMarkup(<AgentToolCard title="model_list" text="工具执行成功" detail={{ eventType: "tool_completed" }} theme={canvasThemes.dark} />);
|
||||
expect(html).toContain("已获取可用模型");
|
||||
|
||||
37
web/test/agent-tool-retry.test.ts
Normal file
37
web/test/agent-tool-retry.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { agentToolRetry, mergeAgentToolRetry } from "@/lib/canvas/agent-tool-retry";
|
||||
import type { CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui";
|
||||
|
||||
function attempt(id: string, count: number, status = "retrying", group = "run:repair:call"): CloudAgentChatMessage {
|
||||
return { id, role: "tool", title: "canvas_apply_ops", text: "缺少操作类型", detail: { eventType: status === "recovered" ? "tool_completed" : "tool_failed", retry: { groupId: group, attempt: count, maxAttempts: 3, status } } };
|
||||
}
|
||||
|
||||
test("retry attempts merge across unrelated reads without mutating previous state", () => {
|
||||
const first = mergeAgentToolRetry([], attempt("1", 1));
|
||||
const messages = [...first, { id: "read", role: "tool" as const, text: "读取画布" }];
|
||||
const merged = mergeAgentToolRetry(messages, attempt("2", 2));
|
||||
expect(merged).toHaveLength(2);
|
||||
expect((merged[0].detail as any).retryAttempts).toHaveLength(2);
|
||||
expect((first[0].detail as any).retryAttempts).toHaveLength(1);
|
||||
expect(mergeAgentToolRetry(merged, attempt("2", 2))).toEqual(merged);
|
||||
});
|
||||
|
||||
test("recovery survives replay and subsequent runs get their own groups", () => {
|
||||
let messages = mergeAgentToolRetry([], attempt("1", 1));
|
||||
messages = mergeAgentToolRetry(messages, attempt("done", 1, "recovered"));
|
||||
messages = mergeAgentToolRetry(messages, attempt("1", 1));
|
||||
expect(agentToolRetry(messages[0].detail)?.status).toBe("recovered");
|
||||
expect((messages[0].detail as any).retryAttempts).toHaveLength(1);
|
||||
expect(mergeAgentToolRetry(messages, attempt("next", 1, "retrying", "other:repair:call"))).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("exhaustion is preserved and legacy failures are not hidden", () => {
|
||||
let messages = mergeAgentToolRetry([], attempt("1", 1));
|
||||
messages = mergeAgentToolRetry(messages, attempt("3", 3, "exhausted"));
|
||||
messages = mergeAgentToolRetry(messages, attempt("same-attempt-replay", 3));
|
||||
messages = mergeAgentToolRetry(messages, attempt("2", 2));
|
||||
expect(agentToolRetry(messages[0].detail)?.status).toBe("exhausted");
|
||||
const legacy: CloudAgentChatMessage = { id: "old", role: "tool", text: "失败", detail: { eventType: "tool_failed" } };
|
||||
expect(mergeAgentToolRetry(messages, legacy)).toBe(messages);
|
||||
expect(agentToolRetry({ retry: { groupId: "x", attempt: 4, maxAttempts: 3, status: "retrying" } })).toBeUndefined();
|
||||
});
|
||||
Reference in New Issue
Block a user