fix(agent): handle task failure by displaying user-friendly error messages in conversation flow

This commit is contained in:
HouYunFei
2026-07-29 13:20:31 +08:00
parent 0f5eb1fbcb
commit ed32e44e69
6 changed files with 34 additions and 7 deletions
+1
View File
@@ -13,6 +13,7 @@
+ [新增] Agent 对话支持 Codex 结构化任务进度,运行期间固定展示并逐项实时更新待处理、进行中和已完成状态。
+ [优化] Agent 长时间等待时显示当前处理阶段、已等待时长和停止提示,并避免新建空会话反复记录历史读取失败。
+ [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。
+ [修复] Agent 在模型繁忙等任务失败时会立即结束等待状态,并在对话和历史中展示中文错误原因与重试建议。
+ [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。
+ [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。
+ [优化] 重构 Canvas Agent 源码目录,按 Agent、画布、服务与通用工具拆分模块职责,提升代码可维护性。
+1 -1
View File
@@ -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 };
+11
View File
@@ -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)
+1 -1
View File
@@ -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<string, unknown> : { value: payload };
session.emitThread(type, threadId, data);
session.emitThread(type, threadId, { ...data, ...(turnId ? { turn_id: turnId } : {}) });
};
void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), turnEmit, attachments, {
threadId,
+1 -1
View File
@@ -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 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。
@@ -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"),