fix: prevent checkpoint pollution + add reset thread button
- 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:
co-authored by
Claude Opus 4.6
parent
bd7f227b11
commit
ffdb88677b
@@ -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",
|
||||
|
||||
@@ -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,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
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user