fix: prevent checkpoint pollution + add reset thread button
Trigger auto deployment for soc-langgraph / build-and-deploy (push) Failing after 28s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 36s

- 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) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-13 01:16:11 +08:00
co-authored by Claude Opus 4.6
parent bd7f227b11
commit ffdb88677b
10 changed files with 216 additions and 5 deletions
+4 -1
View File
@@ -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);
@@ -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,
@@ -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
@@ -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",
+4 -1
View File
@@ -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
@@ -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,
@@ -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<string>();
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;
}
+4 -1
View File
@@ -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
@@ -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,
+76 -1
View File
@@ -9,7 +9,7 @@ type UIMsgLocal = { id: string; type: string; name: string; props: Record<string
import { useState, useRef, useEffect, useCallback, Component, type ReactNode } from "react";
import ComponentMap from "./agent-uis/index.tsx";
import "./index.css";
import { BookOpen, Search, Terminal, Ticket, Zap, Cpu, Bot, Menu, Copy, Check, RefreshCw, Square, Pencil, ChevronDown, Sparkles, Loader2, Clock } from "lucide-react";
import { BookOpen, Search, Terminal, Ticket, Zap, Cpu, Bot, Menu, Copy, Check, RefreshCw, Square, Pencil, ChevronDown, Sparkles, Loader2, Clock, AlertTriangle, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { ThreadSidebar, type ThreadItem, updateThreadLastActive } from "@/components/ThreadSidebar.tsx";
import { ThemeProvider } from "next-themes";
@@ -343,6 +343,42 @@ function App() {
}
}, [currentThreadId]);
// ── Reset corrupted thread ───────────────────────────────────────────────
const [resetLoading, setResetLoading] = useState(false);
const handleResetThread = useCallback(async () => {
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() {
</div>
))}
{/* ── Thread error banner ── */}
{!!thread.error && !thread.isLoading && (
<div className="rounded-xl border border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-950/30 px-4 py-4 flex flex-col gap-3 my-2">
<div className="flex items-start gap-2.5">
<AlertTriangle className="size-4 shrink-0 text-red-500 dark:text-red-400 mt-0.5" />
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium text-red-700 dark:text-red-300">对话遇到问题</p>
<p className="text-xs text-red-600/80 dark:text-red-400/80">
工具执行异常导致对话状态损坏,无法继续当前对话。
</p>
</div>
</div>
<div className="flex items-center gap-2 pl-6">
<button
type="button"
onClick={handleResetThread}
disabled={resetLoading}
className="inline-flex items-center gap-1.5 rounded-lg border border-red-300 dark:border-red-700 bg-white dark:bg-red-900/20 px-3 py-1.5 text-xs font-medium text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/40 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{resetLoading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
{resetLoading ? "重置中..." : "重置此对话"}
</button>
<button
type="button"
onClick={handleNewThread}
disabled={resetLoading}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
<Plus className="size-3.5" />
新建对话
</button>
</div>
</div>
)}
<div ref={bottomRef} />
{/* Scroll-to-bottom floating button */}