From 4677caba01ee59c2f7c1dbdff605b2d853a751bd Mon Sep 17 00:00:00 2001 From: wizardchen Date: Thu, 7 May 2026 16:43:06 +0800 Subject: [PATCH] fix(agent): unify answer rendering when model skips final_answer tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Many models (especially smaller or non-OpenAI-style ones) do not reliably call the structured final_answer tool, even when the system prompt explicitly requires it. Two visible symptoms in the agent UI: 1. The natural-stop path emits the answer as a final_answer event whose content is wrapped in ..., ..., or prefixed with "Final Answer:" / "最终答案:". These wrappers were rendered verbatim into the answer card. 2. When no answer event arrives at all, the trailing thinking event was surfaced via the legacy fallback but kept its collapsed "思考" card styling instead of rendering as a proper answer. This change adds a small unwrap helper and wires it through the answer rendering path: - New util `unwrapFinalAnswerWrappers` strips conservative outer wrappers (XML-style answer tags, fenced ```final_answer code blocks, and leading "Final Answer:" / "最终答案:" prefixes) only when they cover the entire trimmed content, so user markdown that happens to contain similar text is left alone. - `renderAnswerContent` and `getActualContent` apply the unwrap before the markdown renderer / clipboard copy, so users see and copy clean text. - The legacy "promote trailing thinking to final answer" fallback now synthesizes a virtual answer event so it renders with the answer card UI (expanded markdown + copy/add toolbar) instead of the collapsed thinking card. The promoted thinking event is hidden from the intermediate-steps tree to avoid duplication. --- frontend/src/utils/finalAnswer.ts | 71 +++++++++++++++++++ .../chat/components/AgentStreamDisplay.vue | 69 ++++++++++++++---- 2 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 frontend/src/utils/finalAnswer.ts diff --git a/frontend/src/utils/finalAnswer.ts b/frontend/src/utils/finalAnswer.ts new file mode 100644 index 00000000..1dbb8031 --- /dev/null +++ b/frontend/src/utils/finalAnswer.ts @@ -0,0 +1,71 @@ +// finalAnswer.ts +// +// Helpers for normalising LLM-emitted final answers before rendering. +// +// Background: not every model reliably calls our `final_answer` tool. Many +// models — especially smaller ones or those SFT'd on different conventions — +// instead embed the answer inside …, …, +// or prefix it with "Final Answer:" / "最终答案:". When the agent loop accepts +// such a natural-stop response as the final answer, those wrappers leak into +// the rendered output. This module provides a single helper to strip them +// before the markdown renderer sees the text. +// +// The function is intentionally conservative: it only strips a wrapper when +// it covers the *entire* trimmed content. We don't want to corrupt user- +// authored markdown that happens to contain the word "Final Answer:" or an +// XML-style tag in the middle of a sentence. + +const ANSWER_TAG_RE = + /^\s*<(answer|final_answer|final-answer)\b[^>]*>([\s\S]*?)<\/\1>\s*$/i; + +const FENCED_ANSWER_RE = + /^\s*```(?:final_answer|answer)\s*\n?([\s\S]*?)\n?```\s*$/i; + +const ANSWER_PREFIX_RE = + /^\s*(?:final\s*answer|最终答案|答案|答)\s*[::]\s*/i; + +/** + * Remove common "final answer" wrappers that some models emit instead of + * calling the structured `final_answer` tool. Returns the original string + * (trimmed only when stripping happens) when no wrapper is detected. + * + * Recognised wrappers (must cover the entire trimmed content): + * - `…` / `…` (case-insensitive) + * - ```` ```final_answer\n…\n``` ```` fenced code block + * - Leading `Final Answer:` / `最终答案:` / `答:` prefix + */ +export function unwrapFinalAnswerWrappers(content: string): string { + if (!content || typeof content !== 'string') { + return content ?? ''; + } + + let result = content; + let changed = false; + + // Strip outer XML-style answer tags. Loop in case the model nested them + // (e.g. …), but cap iterations + // to avoid pathological inputs. + for (let i = 0; i < 3; i++) { + const tagMatch = result.match(ANSWER_TAG_RE); + if (!tagMatch) break; + result = tagMatch[2]; + changed = true; + } + + // Strip fenced "```final_answer" code block wrappers. + const fencedMatch = result.match(FENCED_ANSWER_RE); + if (fencedMatch) { + result = fencedMatch[1]; + changed = true; + } + + // Strip a leading "Final Answer:" / "最终答案:" prefix when it is the very + // first non-whitespace token. Only applied once. + const prefixMatch = result.match(ANSWER_PREFIX_RE); + if (prefixMatch) { + result = result.slice(prefixMatch[0].length); + changed = true; + } + + return changed ? result.trim() : result; +} diff --git a/frontend/src/views/chat/components/AgentStreamDisplay.vue b/frontend/src/views/chat/components/AgentStreamDisplay.vue index b0effd40..2eca90e9 100644 --- a/frontend/src/views/chat/components/AgentStreamDisplay.vue +++ b/frontend/src/views/chat/components/AgentStreamDisplay.vue @@ -207,7 +207,7 @@ v-if="event.content && event.content.trim()" class="answer-content markdown-content" > -
+
@@ -385,6 +385,7 @@ import { useAuthStore } from '@/stores/auth'; import { useI18n } from 'vue-i18n'; import i18n from '@/i18n'; import { hydrateProtectedFileImages } from '@/utils/security'; +import { unwrapFinalAnswerWrappers } from '@/utils/finalAnswer'; import { buildManualMarkdown, copyTextToClipboard, @@ -1059,12 +1060,34 @@ const buildFullEventList = (stream: any[]) => { return result; }; +// When the agent loop ended via natural-stop (no final_answer tool call) and +// we promote the trailing thinking event into a virtual answer card below +// the tree, we must hide that same thinking event from the tree to avoid +// rendering its content twice. +const promotedThinkingEventId = computed(() => { + const final = finalContent.value; + if (!final || final.type !== 'thinking') return null; + // Only promote when there's no real answer event content already. + const stream = eventStream.value; + if (!stream || !Array.isArray(stream)) return null; + const hasRealAnswer = stream.some( + (e: any) => e.type === 'answer' && e.content && e.content.trim() + ); + if (hasRealAnswer) return null; + return final.event_id || null; +}); + // Intermediate events (tree children: everything except answer) const intermediateEvents = computed(() => { const stream = eventStream.value; if (!stream || !Array.isArray(stream)) return []; const result = buildFullEventList(stream); - return result.filter((e: any) => e.type !== 'answer' && e.type !== 'agent_complete'); + const promotedId = promotedThinkingEventId.value; + return result.filter((e: any) => { + if (e.type === 'answer' || e.type === 'agent_complete') return false; + if (promotedId && e.type === 'thinking' && e.event_id === promotedId) return false; + return true; + }); }); // Events to display (non-tree: before answer starts show all, after answer starts show only answer) @@ -1105,14 +1128,23 @@ const displayEvents = computed(() => { } if (final.type === 'thinking') { - const thinkingFiltered = result.filter((e: any) => + // The agent loop ended via natural-stop (the model wrote its answer as + // free text instead of calling final_answer). Synthesize a virtual + // `answer` event from the trailing thinking content so it renders with + // the answer card UI (expanded markdown + copy/add toolbar) rather than + // the collapsed "思考" card. The original thinking event is still in + // the intermediate-steps tree when applicable. + const thinking = result.find((e: any) => e.type === 'thinking' && e.event_id === final.event_id ); - if (final.showAnswerToolbar) { - const answerDoneEvents = result.filter((e: any) => e.type === 'answer' && e.done === true); - return [...thinkingFiltered, ...answerDoneEvents]; - } - return thinkingFiltered; + if (!thinking || !thinking.content) return result; + return [{ + type: 'answer', + event_id: thinking.event_id, + content: thinking.content, + done: true, + _promoted_from_thinking: true, + }]; } return result; @@ -1761,6 +1793,15 @@ const renderMarkdownContent = (content: any): string => { return DOMPurify.sanitize(protectedHTML, DOMPurifyConfig); }; +// Renders an answer event's content. Strips final-answer wrappers +// (e.g. …, "Final Answer:") that some models emit instead +// of calling the structured final_answer tool, then delegates to the +// standard markdown renderer. +const renderAnswerContent = (content: any): string => { + const contentStr = typeof content === 'string' ? content : String(content || ''); + return renderMarkdownContent(unwrapFinalAnswerWrappers(contentStr)); +}; + // Legacy Markdown rendering function (kept for summaries) const renderMarkdown = (content: any): string => { const contentStr = typeof content === 'string' ? content : String(content || ''); @@ -2168,24 +2209,26 @@ const formatJSON = (obj: any): string => { } }; -// Helper function to get actual content (from answer or last thinking) +// Helper function to get actual content (from answer or last thinking). +// Strips final-answer wrappers (e.g. …, "Final Answer:") +// so callers like copy and add-to-knowledge get clean text. const getActualContent = (answerEvent: any): string => { // First try to get content from answer event const answerContent = (answerEvent?.content || '').trim(); if (answerContent) { - return answerContent; + return unwrapFinalAnswerWrappers(answerContent).trim(); } - + // If answer is empty, try to get from last thinking const stream = eventStream.value; if (stream && Array.isArray(stream)) { const thinkingEvents = stream.filter((e: any) => e.type === 'thinking' && e.content && e.content.trim()); if (thinkingEvents.length > 0) { const lastThinking = thinkingEvents[thinkingEvents.length - 1]; - return (lastThinking.content || '').trim(); + return unwrapFinalAnswerWrappers((lastThinking.content || '').trim()).trim(); } } - + return ''; };