From ffdb88677b9cbcd90f83ecc0accb9a88cfe79f2e Mon Sep 17 00:00:00 2001 From: gongzhiyong Date: Mon, 13 Apr 2026 01:16:11 +0800 Subject: [PATCH] fix: prevent checkpoint pollution + add reset thread button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add checkpoint guard in all 4 tool-executors ensuring every tool_call_id gets a ToolMessage response - Add cleanPollutedToolCalls utility to heal corrupted message history - Add error banner with "重置此对话" button in frontend Co-Authored-By: Claude Opus 4.6 (1M context) --- langgraph/src/agent/coder/nodes/agent.ts | 5 +- .../src/agent/coder/nodes/tool-executor.ts | 13 ++++ langgraph/src/agent/enterprise/nodes/agent.ts | 5 +- .../agent/enterprise/nodes/tool-executor.ts | 13 ++++ langgraph/src/agent/searcher/nodes/agent.ts | 5 +- .../src/agent/searcher/nodes/tool-executor.ts | 13 ++++ .../agent/utils/clean-polluted-tool-calls.ts | 72 +++++++++++++++++ langgraph/src/agent/writer/nodes/agent.ts | 5 +- .../src/agent/writer/nodes/tool-executor.ts | 13 ++++ langgraph/src/main.tsx | 77 ++++++++++++++++++- 10 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 langgraph/src/agent/utils/clean-polluted-tool-calls.ts diff --git a/langgraph/src/agent/coder/nodes/agent.ts b/langgraph/src/agent/coder/nodes/agent.ts index 0e3045c..5784d74 100644 --- a/langgraph/src/agent/coder/nodes/agent.ts +++ b/langgraph/src/agent/coder/nodes/agent.ts @@ -3,6 +3,7 @@ */ import { createLlm, type ModelMode } from "@/agent/utils/create-llm"; import { truncateMessages } from "@/agent/utils/truncate-messages"; +import { cleanPollutedToolCalls } from "@/agent/utils/clean-polluted-tool-calls"; import { LangGraphRunnableConfig } from "@langchain/langgraph"; import { CoderState, CoderUpdate } from "../types.js"; import { z } from "zod"; @@ -92,9 +93,11 @@ export async function agentNode( const llm = createLlm({ modelMode }); const truncated = truncateMessages(state.messages); + // Clean up polluted messages before sending to LLM + const cleanedMessages = cleanPollutedToolCalls(truncated); const messagesWithSystem = [ { role: "system" as const, content: SYSTEM_PROMPT }, - ...truncated, + ...cleanedMessages, ]; const message = await llm.bindTools([...CODER_TOOLS], { parallel_tool_calls: false }).invoke(messagesWithSystem); diff --git a/langgraph/src/agent/coder/nodes/tool-executor.ts b/langgraph/src/agent/coder/nodes/tool-executor.ts index e5e3fe7..507f85e 100644 --- a/langgraph/src/agent/coder/nodes/tool-executor.ts +++ b/langgraph/src/agent/coder/nodes/tool-executor.ts @@ -161,6 +161,19 @@ export async function toolExecutorNode( } } + // Ensure every tool_call has a corresponding ToolMessage (prevent checkpoint pollution) + const respondedIds = new Set(toolMessages.map(m => m.tool_call_id)); + for (const tc of toolCalls) { + if (tc.id && !respondedIds.has(tc.id)) { + console.error(`[checkpoint-guard] Missing ToolMessage for tool_call_id: ${tc.id}, tool: ${tc.name}`); + toolMessages.push({ + role: "tool" as const, + tool_call_id: tc.id, + content: JSON.stringify({ ok: false, tool: tc.name, error: "工具执行异常,未能返回结果" }), + }); + } + } + return { messages: toolMessages, ui: ui.items, diff --git a/langgraph/src/agent/enterprise/nodes/agent.ts b/langgraph/src/agent/enterprise/nodes/agent.ts index 52d78ea..18d9191 100644 --- a/langgraph/src/agent/enterprise/nodes/agent.ts +++ b/langgraph/src/agent/enterprise/nodes/agent.ts @@ -4,6 +4,7 @@ */ import { createLlm, type ModelMode } from "@/agent/utils/create-llm"; import { truncateMessages } from "@/agent/utils/truncate-messages"; +import { cleanPollutedToolCalls } from "@/agent/utils/clean-polluted-tool-calls"; import { LangGraphRunnableConfig } from "@langchain/langgraph"; import { EnterpriseState, EnterpriseUpdate } from "../types.js"; import { filterTools } from "./tool-defs.js"; @@ -134,9 +135,11 @@ export async function agentNode( const tools = filterTools(modelMode, enabledTools); const truncated = truncateMessages(state.messages); + // Clean up polluted messages before sending to LLM + const cleanedMessages = cleanPollutedToolCalls(truncated); const messagesWithSystem = [ { role: "system" as const, content: SYSTEM_PROMPT }, - ...truncated, + ...cleanedMessages, ]; // If no tools available after filtering, invoke LLM without tool binding diff --git a/langgraph/src/agent/enterprise/nodes/tool-executor.ts b/langgraph/src/agent/enterprise/nodes/tool-executor.ts index dcabe71..893af54 100644 --- a/langgraph/src/agent/enterprise/nodes/tool-executor.ts +++ b/langgraph/src/agent/enterprise/nodes/tool-executor.ts @@ -1050,6 +1050,19 @@ export async function toolExecutorNode( } } + // Ensure every tool_call has a corresponding ToolMessage (prevent checkpoint pollution) + const respondedIds = new Set(toolMessages.map(m => m.tool_call_id)); + for (const tc of toolCalls) { + if (tc.id && !respondedIds.has(tc.id)) { + console.error(`[checkpoint-guard] Missing ToolMessage for tool_call_id: ${tc.id}, tool: ${tc.name}`); + toolMessages.push({ + role: "tool" as const, + tool_call_id: tc.id, + content: JSON.stringify({ ok: false, tool: tc.name, error: "工具执行异常,未能返回结果" }), + }); + } + } + // Push next-actions card based on successful tool results const successfulTools = statusList.filter( (s) => s.status === "ok" || s.status === "empty" || s.status === "fallback_success", diff --git a/langgraph/src/agent/searcher/nodes/agent.ts b/langgraph/src/agent/searcher/nodes/agent.ts index 329ca61..34472c2 100644 --- a/langgraph/src/agent/searcher/nodes/agent.ts +++ b/langgraph/src/agent/searcher/nodes/agent.ts @@ -3,6 +3,7 @@ */ import { createLlm, type ModelMode } from "@/agent/utils/create-llm"; import { truncateMessages } from "@/agent/utils/truncate-messages"; +import { cleanPollutedToolCalls } from "@/agent/utils/clean-polluted-tool-calls"; import { LangGraphRunnableConfig } from "@langchain/langgraph"; import { SearcherState, SearcherUpdate } from "../types.js"; import { z } from "zod"; @@ -92,9 +93,11 @@ export async function agentNode( const llm = createLlm({ modelMode }); const truncated = truncateMessages(state.messages); + // Clean up polluted messages before sending to LLM + const cleanedMessages = cleanPollutedToolCalls(truncated); const messagesWithSystem = [ { role: "system" as const, content: SYSTEM_PROMPT }, - ...truncated, + ...cleanedMessages, ]; const message = await llm diff --git a/langgraph/src/agent/searcher/nodes/tool-executor.ts b/langgraph/src/agent/searcher/nodes/tool-executor.ts index f816cd0..26d9fbc 100644 --- a/langgraph/src/agent/searcher/nodes/tool-executor.ts +++ b/langgraph/src/agent/searcher/nodes/tool-executor.ts @@ -394,6 +394,19 @@ export async function toolExecutorNode( } } + // Ensure every tool_call has a corresponding ToolMessage (prevent checkpoint pollution) + const respondedIds = new Set(toolMessages.map(m => m.tool_call_id)); + for (const tc of toolCalls) { + if (tc.id && !respondedIds.has(tc.id)) { + console.error(`[checkpoint-guard] Missing ToolMessage for tool_call_id: ${tc.id}, tool: ${tc.name}`); + toolMessages.push({ + role: "tool" as const, + tool_call_id: tc.id, + content: JSON.stringify({ ok: false, tool: tc.name, error: "工具执行异常,未能返回结果" }), + }); + } + } + return { messages: toolMessages, ui: ui.items, diff --git a/langgraph/src/agent/utils/clean-polluted-tool-calls.ts b/langgraph/src/agent/utils/clean-polluted-tool-calls.ts new file mode 100644 index 0000000..ad91045 --- /dev/null +++ b/langgraph/src/agent/utils/clean-polluted-tool-calls.ts @@ -0,0 +1,72 @@ +/** + * Utility to clean polluted tool_calls from message history. + * + * Problem: When a tool-executor fails to return a ToolMessage for every tool_call_id, + * the checkpoint stores an AIMessage with dangling tool_calls. On the next turn, + * Azure OpenAI rejects the request with 400 because it expects every tool_call to + * have a corresponding ToolMessage. + * + * This function scans the message array and: + * 1. If an AIMessage's tool_calls ALL lack ToolMessage responses → remove that AIMessage + * 2. If only SOME lack responses → inject placeholder error ToolMessages for the missing ones + */ +import { BaseMessage, AIMessage, ToolMessage } from "@langchain/core/messages"; + +export function cleanPollutedToolCalls(messages: BaseMessage[]): BaseMessage[] { + // Build a set of all tool_call_ids that have a ToolMessage response + const respondedIds = new Set(); + for (const m of messages) { + if (m instanceof ToolMessage || (m as unknown as { role?: string }).role === "tool") { + const tcId = + (m as ToolMessage).tool_call_id ?? + (m as unknown as { tool_call_id?: string }).tool_call_id; + if (tcId) respondedIds.add(tcId); + } + } + + const result: BaseMessage[] = []; + + for (const m of messages) { + // Check if this is an AIMessage with tool_calls + const toolCalls = (m as AIMessage).tool_calls; + if (toolCalls && toolCalls.length > 0) { + const missingIds = toolCalls + .filter((tc) => tc.id && !respondedIds.has(tc.id)) + .map((tc) => tc.id!); + + if (missingIds.length === 0) { + // All tool_calls have responses — keep as is + result.push(m); + } else if (missingIds.length === toolCalls.length) { + // ALL tool_calls are missing responses — drop the entire AIMessage + console.warn( + `[clean-polluted] Removing AIMessage with ${toolCalls.length} dangling tool_calls: ${missingIds.join(", ")}`, + ); + // Do NOT push this message + } else { + // Partial: some have responses, some don't — keep the AIMessage but inject placeholders + result.push(m); + for (const id of missingIds) { + const tcName = toolCalls.find((tc) => tc.id === id)?.name ?? "unknown"; + console.warn( + `[clean-polluted] Injecting placeholder ToolMessage for orphaned tool_call_id: ${id} (tool: ${tcName})`, + ); + result.push( + new ToolMessage({ + tool_call_id: id, + content: JSON.stringify({ + ok: false, + tool: tcName, + error: "工具执行异常,未能返回结果(已自动补全)", + }), + }), + ); + } + } + } else { + result.push(m); + } + } + + return result; +} diff --git a/langgraph/src/agent/writer/nodes/agent.ts b/langgraph/src/agent/writer/nodes/agent.ts index 1eabba7..fba75e9 100644 --- a/langgraph/src/agent/writer/nodes/agent.ts +++ b/langgraph/src/agent/writer/nodes/agent.ts @@ -4,6 +4,7 @@ */ import { createLlm, type ModelMode } from "@/agent/utils/create-llm"; import { truncateMessages } from "@/agent/utils/truncate-messages"; +import { cleanPollutedToolCalls } from "@/agent/utils/clean-polluted-tool-calls"; import { LangGraphRunnableConfig } from "@langchain/langgraph"; import { WriterState, WriterUpdate } from "../types.js"; import { ALL_WRITER_TOOLS } from "./tool-defs.js"; @@ -65,9 +66,11 @@ export async function writerAgentNode( const llm = createLlm({ modelMode, maxTokens: 8192 }); const truncated = truncateMessages(state.messages); + // Clean up polluted messages before sending to LLM + const cleanedMessages = cleanPollutedToolCalls(truncated); const messagesWithSystem = [ { role: "system" as const, content: SYSTEM_PROMPT }, - ...truncated, + ...cleanedMessages, ]; const message = await llm diff --git a/langgraph/src/agent/writer/nodes/tool-executor.ts b/langgraph/src/agent/writer/nodes/tool-executor.ts index 994ab4b..6513d1b 100644 --- a/langgraph/src/agent/writer/nodes/tool-executor.ts +++ b/langgraph/src/agent/writer/nodes/tool-executor.ts @@ -403,6 +403,19 @@ export async function writerToolExecutorNode( } } + // Ensure every tool_call has a corresponding ToolMessage (prevent checkpoint pollution) + const respondedIds = new Set(toolMessages.map(m => m.tool_call_id)); + for (const tc of toolCalls) { + if (tc.id && !respondedIds.has(tc.id)) { + console.error(`[checkpoint-guard] Missing ToolMessage for tool_call_id: ${tc.id}, tool: ${tc.name}`); + toolMessages.push({ + role: "tool" as const, + tool_call_id: tc.id, + content: JSON.stringify({ ok: false, tool: tc.name, error: "工具执行异常,未能返回结果" }), + }); + } + } + return { messages: toolMessages, ui: ui.items, diff --git a/langgraph/src/main.tsx b/langgraph/src/main.tsx index 07a872d..57e3025 100644 --- a/langgraph/src/main.tsx +++ b/langgraph/src/main.tsx @@ -9,7 +9,7 @@ type UIMsgLocal = { id: string; type: string; name: string; props: Record { + if (!currentThreadId || resetLoading) return; + setResetLoading(true); + try { + // Delete the corrupted thread + await client.threads.delete(currentThreadId).catch(() => {/* ignore if already gone */}); + // Create a fresh replacement thread + const newThread = await client.threads.create(); + const newId = newThread.thread_id; + // Replace old thread entry in sidebar at the same position + setThreads((prev) => + prev.map((t) => + t.thread_id === currentThreadId ? (newThread as ThreadItem) : t, + ), + ); + // Clear messages and switch to new thread + setHistoricalMessages([]); + setHistoricalUi([]); + setCurrentThreadId(newId); + } catch { + // If anything fails, just create a new thread (append to top) + try { + const newThread = await client.threads.create(); + setThreads((prev) => [newThread as ThreadItem, ...prev]); + setHistoricalMessages([]); + setHistoricalUi([]); + setCurrentThreadId(newThread.thread_id); + } catch { /* give up gracefully */ } + } finally { + setResetLoading(false); + } + }, [currentThreadId, resetLoading]); + // Auto-resize textarea useEffect(() => { const el = textareaRef.current; @@ -941,6 +977,45 @@ function App() { ))} + {/* ── Thread error banner ── */} + {!!thread.error && !thread.isLoading && ( +
+
+ +
+

对话遇到问题

+

+ 工具执行异常导致对话状态损坏,无法继续当前对话。 +

+
+
+
+ + +
+
+ )} +
{/* Scroll-to-bottom floating button */}