From ed32e44e69f66a641aa51ef8aaf90093895556b3 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Wed, 29 Jul 2026 13:20:31 +0800 Subject: [PATCH] fix(agent): handle task failure by displaying user-friendly error messages in conversation flow --- CHANGELOG.md | 1 + canvas-agent/src/agent/codex-client.ts | 2 +- canvas-agent/src/agent/codex-history.ts | 11 +++++++++ canvas-agent/src/server/http.ts | 2 +- docs/content/docs/progress/pending-test.mdx | 2 +- .../canvas/canvas-local-agent-panel.tsx | 23 +++++++++++++++---- 6 files changed, 34 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53755ed..1d24998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ + [新增] Agent 对话支持 Codex 结构化任务进度,运行期间固定展示并逐项实时更新待处理、进行中和已完成状态。 + [优化] Agent 长时间等待时显示当前处理阶段、已等待时长和停止提示,并避免新建空会话反复记录历史读取失败。 + [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。 ++ [修复] Agent 在模型繁忙等任务失败时会立即结束等待状态,并在对话和历史中展示中文错误原因与重试建议。 + [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。 + [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。 + [优化] 重构 Canvas Agent 源码目录,按 Agent、画布、服务与通用工具拆分模块职责,提升代码可维护性。 diff --git a/canvas-agent/src/agent/codex-client.ts b/canvas-agent/src/agent/codex-client.ts index ed51d7a..9511883 100644 --- a/canvas-agent/src/agent/codex-client.ts +++ b/canvas-agent/src/agent/codex-client.ts @@ -327,7 +327,7 @@ function normalizeCodexNotification(method: string, params: JsonRecord): AgentEv const scope = codexEventScope(params); if (method === "thread/started") return { type: "thread.started", ...scope }; if (method === "turn/started") return { type: "turn.started", ...scope }; - if (method === "turn/completed") return { type: "turn.completed", status: field(field(params, "turn"), "status"), usage: null, duration_ms: field(field(params, "turn"), "durationMs"), ...scope }; + if (method === "turn/completed") return { type: "turn.completed", status: field(field(params, "turn"), "status"), error: field(field(params, "turn"), "error"), usage: null, duration_ms: field(field(params, "turn"), "durationMs"), ...scope }; if (method === "turn/plan/updated") return { type: "plan.updated", explanation: field(params, "explanation"), plan: field(params, "plan"), ...scope }; if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope }; if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")), ...scope }; diff --git a/canvas-agent/src/agent/codex-history.ts b/canvas-agent/src/agent/codex-history.ts index 423e2f6..653e0f4 100644 --- a/canvas-agent/src/agent/codex-history.ts +++ b/canvas-agent/src/agent/codex-history.ts @@ -26,6 +26,7 @@ export function threadMessages(thread: unknown, planUpdates: CodexPlanUpdate[] = const messages: AgentHistoryMessage[] = []; turns.forEach((turn, turnIndex) => { const turnId = String(field(turn, "id") || turnIndex); + const turnError = String(field(field(turn, "error"), "message") || "").trim(); const planMessage = structuredPlanMessage(plansByTurn.get(turnId) || { threadId: "", turnId, explanation: stringOrNull(field(turn, "explanation")), plan: arrayValue(field(turn, "plan")) as CodexPlanUpdate["plan"], turnStatus: String(field(turn, "status") || "") }); let planAdded = false; arrayValue(field(turn, "items")).forEach((item, itemIndex) => { @@ -72,6 +73,10 @@ export function threadMessages(thread: unknown, planUpdates: CodexPlanUpdate[] = if (type === "collabToolCall") messages.push({ id, role: "tool", title: "协作处理", text: "已完成协作任务", detail: { kind: "tool", status: field(item, "status") } }); }); if (planMessage && !planAdded) messages.push(planMessage); + if (turnError) { + const error = userFacingCodexError(turnError); + messages.push({ id: `error-${turnId}`, role: "error", title: error.title, text: error.text }); + } }); return messages.filter((item) => item.text).slice(-120); } @@ -101,6 +106,12 @@ function planStatus(tasks: Array<{ status: string }>, turnStatus?: string) { return turnStatus === "completed" ? "finished" : "inProgress"; } +/** 将常见 Codex 错误转换为普通用户可理解的提示。 */ +function userFacingCodexError(message: string) { + if (/selected model is at capacity/i.test(message)) return { title: "模型暂时繁忙", text: "当前选择的模型请求量过大,暂时无法处理。请稍后重试,或切换其他模型后再试。" }; + return { title: "任务失败", text: message || "Codex 未能完成本次任务,请稍后重试。" }; +} + /** 提取用户输入条目中的文本与附件占位信息。 */ function userInputText(content: unknown) { return arrayValue(content) diff --git a/canvas-agent/src/server/http.ts b/canvas-agent/src/server/http.ts index af8fd3e..cf6036f 100644 --- a/canvas-agent/src/server/http.ts +++ b/canvas-agent/src/server/http.ts @@ -145,7 +145,7 @@ export function startHttpServer() { /** 将当前 turn 事件固定广播到实际线程。 */ const turnEmit = (type: string, payload: unknown) => { const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record : { value: payload }; - session.emitThread(type, threadId, data); + session.emitThread(type, threadId, { ...data, ...(turnId ? { turn_id: turnId } : {}) }); }; void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), turnEmit, attachments, { threadId, diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 11e5578..1274aac 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -9,7 +9,7 @@ description: 当前版本已实现但仍需人工验证的变更项 - Canvas Agent Debug:使用 `npx -y @basketikun/canvas-agent --debug` 启动后,终端应按“时间 级别 消息 详情”的纯文本单行格式输出日志并显示日志文件路径,`~/.infinite-canvas/logs/` 下应按启动日期生成相同格式的 `canvas-agent-YYYY-MM-DD.log`,同一天多次启动应追加到同一文件;普通启动保持原有简洁输出,日志中不应出现连接 token 或图片 Data URL 原文。 - Agent HTTP 诊断日志:网页发送一条普通消息后,本地 Debug 日志不应重复输出 `/health`、`/canvas/state`、`/canvas/activate` 成功请求、流式增量或完整会话响应,只保留 HTTP 请求与 Codex 生命周期摘要;右侧「日志」应以单行时间线展示发送、开始、回复、工具、完成用量和错误,不再输出 userMessage started/completed、流式摘要、重复 threadId 或大段原始 JSON。 - Agent 对话统计:用户消息应右对齐并使用透明无边框、无气泡背景的简洁样式,用户消息和 Codex 回复下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计。 -- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现 GPT 图标和“正在思考...”,任务运行期间不应闪退;工具完成后应显示 Codex 正在继续处理及已等待时长,等待超过 30 秒时提示可继续等待或停止本轮;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。新建尚未发送首条消息的空会话不应反复出现历史读取失败。 +- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现 GPT 图标和“正在思考...”,任务运行期间不应闪退;工具完成后应显示 Codex 正在继续处理及已等待时长,等待超过 30 秒时提示可继续等待或停止本轮;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。模型繁忙等任务失败时应立即结束等待状态,在对话中显示中文错误原因和重试建议,诊断日志不应再把失败轮次记为“处理完成”,刷新历史后错误仍应保留。新建尚未发送首条消息的空会话不应反复出现历史读取失败。 - Agent 流式交互性能:发送长回复时文字应连续平滑出现,输入框、滚动和画布操作不应随回复变长而明显卡顿;长历史会话中只有当前流式消息持续更新,屏幕外消息不应造成明显布局压力;任务完成后应通过 SSE 自动同步完整历史,不再持续请求 `/health` 轮询状态。 - Agent 过程时间线:Codex 运行时应在对话中依次显示中文的任务进度、思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动;当前轮结构化任务进度应固定在对话可视区域,不随后续消息滚出屏幕,并逐项实时更新「待处理」「进行中」「已完成」状态,任务结束后恢复为普通历史消息;同一操作应原位更新而不是生成重复卡片;命令详情只展示工作目录、耗时、退出状态和运行输出,文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON,刷新历史会话后仍保持相同展示。 - Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。 diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index f589ab8..8a458ba 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -240,8 +240,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb if (!data) return; enqueueEvent(() => { if (!isCurrentThreadEvent(data)) return; - addMessage({ role: "error", title: "错误", text: normalizeText(data.message) }); - addEventLog("错误", data.message, data.message); + showAgentError(data.message, data.turn_id); }); }); source.onerror = () => { @@ -664,6 +663,14 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, detail } : message)) }); }; + const showAgentError = (value: unknown, turnId?: string) => { + const error = agentErrorView(value); + const item = { id: turnId ? `error-${turnId}` : createId(), role: "error" as const, title: error.title, text: error.text }; + upsertActivityMessage(item); + setAgentState({ activity: "处理失败", waiting: false, sending: false }); + addEventLog("处理失败", error.text, value); + }; + const handleAgentEvent = (event: AgentEventPayload) => { if (event.type === "usage.updated") setAgentState({ tokenUsage: eventUsage(event) }); const log = formatAgentEventLog(event); @@ -699,6 +706,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb if (event.type === "turn.completed") { if (event.turn_id) finishPlanActivity(event.turn_id, event.status); setAgentState({ messages: useAgentStore.getState().messages.map((message) => (message.streamId ? { ...message, streamId: undefined } : message)) }); + if (event.status === "failed") showAgentError(event.error?.message, event.turn_id); } const item = formatAgentEvent(event); if (item) addMessage(item); @@ -1461,8 +1469,9 @@ function formatAgentEventLog(event: AgentEventPayload) { const tasks = planTasks(event.plan); return { title: "更新任务进度", text: `已完成 ${tasks.filter((item) => item.status === "completed").length}/${tasks.length} 项` }; } - if (event.type === "turn.completed") return { title: "处理完成", text: turnSummary(event) }; - if (event.type === "turn.failed" || event.type === "error") return { title: "处理失败", text: event.message || event.error?.message || "未知错误" }; + if (event.type === "turn.completed" && event.status === "failed") return { title: "处理失败", text: agentErrorView(event.error?.message).text }; + if (event.type === "turn.completed") return { title: event.status === "interrupted" ? "处理已停止" : "处理完成", text: turnSummary(event) }; + if (event.type === "turn.failed" || event.type === "error") return { title: "处理失败", text: agentErrorView(event.message || event.error?.message).text }; if (event.type === "item.started" && isMcpToolItem(item)) return { title: "调用工具", text: toolName(String(item?.tool || "")) }; if (event.type === "item.completed" && isMcpToolItem(item)) return { title: item.error ? "工具失败" : "工具完成", text: `${toolName(String(item?.tool || ""))}${item.error?.message ? ` · ${item.error.message}` : ""}` }; if (event.type === "item.completed" && item?.type === "agent_message") return { title: "收到回复", text: compactText(stringText(item.text)) }; @@ -1473,6 +1482,12 @@ function turnSummary(event: AgentEventPayload) { return event.duration_ms ? `${(event.duration_ms / 1000).toFixed(1)} 秒` : "完成"; } +function agentErrorView(value: unknown) { + const text = normalizeText(value); + if (/selected model is at capacity/i.test(text)) return { title: "模型暂时繁忙", text: "当前选择的模型请求量过大,暂时无法处理。请稍后重试,或切换其他模型后再试。" }; + return { title: "任务失败", text: text || "Codex 未能完成本次任务,请稍后重试。" }; +} + function eventUsage(event: AgentEventPayload): AgentTokenUsage { return { input: numberField(event.usage, "input_tokens"),