// finalAnswer.ts // // Helpers for normalising LLM-emitted final answers before rendering. // // Background: the agent ends a turn by writing its answer as plain assistant // text. Many models — especially smaller ones or those SFT'd on different // conventions — wrap that 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 wrap their * plain-text answer in. 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; } const THINK_BLOCK_RE = /]*>[\s\S]*?<\/think>/gi; /** * Normalise content for cross-event comparison. Strips … * blocks (which appear in raw thinking chunks but not in the final answer * event), strips final-answer wrappers, and collapses whitespace. Used to * detect when the natural-stop path emits the same content twice (once as * streaming thinking chunks and once as a final answer event). */ export function normaliseForComparison(content: string): string { if (!content || typeof content !== 'string') return ''; const stripped = content.replace(THINK_BLOCK_RE, ''); const unwrapped = unwrapFinalAnswerWrappers(stripped); return unwrapped.replace(/\s+/g, ' ').trim(); } /** * Returns true when `thinking` and `answer` represent the same final answer * after normalisation. Tolerates small differences (≤2% length delta with * matching head/tail) to absorb chunk-boundary whitespace noise. */ export function thinkingEqualsAnswer(thinking: string, answer: string): boolean { const a = normaliseForComparison(thinking); const b = normaliseForComparison(answer); if (!a || !b) return false; if (a === b) return true; const maxLen = Math.max(a.length, b.length); // Require at least ~80 chars of signal to avoid matching trivial prefixes. if (maxLen < 80) return false; const lenDelta = Math.abs(a.length - b.length); if (lenDelta > maxLen * 0.02) return false; const head = 50; const tail = 50; return ( a.slice(0, head) === b.slice(0, head) && a.slice(-tail) === b.slice(-tail) ); }