From eb400ab1e90ff1801415b621c1c553cc6b500a27 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Sat, 4 Jul 2026 18:10:51 -0600 Subject: [PATCH] Assistant: suppress repeated render-tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the client returns a render tool's result, the model sometimes re-issues the identical call with a fresh id on the follow-up run, painting a second copy of the spending-summary card (observed live on MAF 1.10 / CK 1.62). DeduplicateToolCallMiddleware now tracks which render tools already ran in the current user turn and drops repeats — their streamed TOOL_CALL_* events and their entries in the run's MESSAGES_SNAPSHOT. The system prompt also tells the model the card is already displayed once its "ok" result is present. Co-Authored-By: Claude Fable 5 --- backend/app/agent/agent.py | 3 ++ frontend/server.ts | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/backend/app/agent/agent.py b/backend/app/agent/agent.py index b490fab..e6c3025 100644 --- a/backend/app/agent/agent.py +++ b/backend/app/agent/agent.py @@ -56,6 +56,9 @@ Generative UI — render tools: nothing. The rendered card IS the complete response. Any text you write in the same message as a render call will appear as a duplicate below the card, which is wrong. +- When a render_spending_summary tool result ("ok") is already present for + the current question, the card is already on screen. Do NOT call the tool + again and do NOT add text — end your response with no further output. """ diff --git a/frontend/server.ts b/frontend/server.ts index ff2b6f1..90d1925 100644 --- a/frontend/server.ts +++ b/frontend/server.ts @@ -120,6 +120,27 @@ class DeduplicateToolCallMiddleware extends (Middleware as any) { const open = new Set(); // tool call IDs currently in progress const closed = new Set(); // tool call IDs that already received END + // Render tools are display-once: after the client returns their result, + // the model sometimes re-issues the same call (fresh id) on the + // follow-up run, painting a second identical card. Track which render + // tools already ran in the current user turn (from the request's own + // message history) and suppress repeats — both their streamed events + // and their entries in this run's MESSAGES_SNAPSHOT. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inputMessages: any[] = Array.isArray(input?.messages) ? input.messages : []; + let lastUserIdx = -1; + inputMessages.forEach((m, i) => { if (m?.role === "user") lastUserIdx = i; }); + const renderedThisTurn = new Set(); + for (const m of inputMessages.slice(lastUserIdx + 1)) { + const calls = m?.toolCalls ?? m?.tool_calls; + if (m?.role !== "assistant" || !Array.isArray(calls)) continue; + for (const tc of calls) { + const name = tc?.function?.name; + if (name && RENDER_TOOLS.has(name)) renderedThisTurn.add(name); + } + } + const suppressed = new Set(); // toolCallIds of dropped repeats + // eslint-disable-next-line @typescript-eslint/no-explicit-any return new Observable((observer) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -132,13 +153,51 @@ class DeduplicateToolCallMiddleware extends (Middleware as any) { if (type === EventType.TOOL_CALL_START) { if (!id || closed.has(id) || open.has(id)) return; // duplicate + const name: string | undefined = event?.toolCallName; + if (name && RENDER_TOOLS.has(name)) { + if (renderedThisTurn.has(name)) { + suppressed.add(id); + return; // repeat render call — drop the whole call + } + renderedThisTurn.add(name); + } open.add(id); } else if (type === EventType.TOOL_CALL_ARGS || type === EventType.TOOL_CALL_END) { + if (id && suppressed.has(id)) return; if (id && closed.has(id)) return; // already completed, drop if (type === EventType.TOOL_CALL_END && id) { open.delete(id); closed.add(id); } + } else if (type === EventType.MESSAGES_SNAPSHOT && suppressed.size > 0) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const msgs: any[] = Array.isArray(event?.messages) ? event.messages : []; + const cleaned = msgs + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((m: any) => { + const calls = m?.toolCalls ?? m?.tool_calls; + if (m?.role !== "assistant" || !Array.isArray(calls)) return m; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const kept = calls.filter((tc: any) => !suppressed.has(String(tc?.id ?? ""))); + if (kept.length === calls.length) return m; + const { toolCalls: _a, tool_calls: _b, ...rest } = m; + return kept.length > 0 ? { ...rest, toolCalls: kept } : rest; + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .filter((m: any) => { + if (m?.role === "tool" && suppressed.has(String(m?.toolCallId ?? m?.tool_call_id ?? ""))) { + return false; + } + if (m?.role === "assistant") { + const calls = m?.toolCalls ?? m?.tool_calls; + const hasCalls = Array.isArray(calls) && calls.length > 0; + const hasText = typeof m?.content === "string" && m.content.length > 0; + if (!hasCalls && !hasText) return false; + } + return true; + }); + observer.next({ ...event, messages: cleaned }); + return; } observer.next(event); // emit raw BaseEvent