/** Tool names that must never appear as pasted JSON in user-visible bot text. */ export const UNSPEAKABLE_DELEGATION_TOOLS = new Set([ "handoff_to_bot", "message_bot", "message_agent", "message_user", "ask_user", "run_subagent", "spawn_bot", ]); export type ProseToolCall = { name: string; parameters: Record; }; export type SanitizedBotReply = { text: string; proseToolCalls: ProseToolCall[]; strippedRolePrefix: boolean; }; const ASSISTANT_ROLE_PREFIX = /^assistant:\s*/i; /** Strip a leaked chat-template role prefix from model output. */ export function stripAssistantRolePrefix(text: string): string { return text.replace(ASSISTANT_ROLE_PREFIX, ""); } const TOOL_META_PREAMBLE = /^(?:[^\n]*\b(?:json response|tool[- ]call|handoff_to_bot|provide the json|i'll provide the json|handoff is given|since the handoff)[^\n]*\n?)+/gim; const TRAILING_TOOL_META_LINE = /^(?:since the handoff is given|i(?:'|’)ll provide the json|here(?:'|’)s the json|the json response).*?:?\s*$/i; /** Remove narration that only exists to introduce a pasted tool-call blob. */ export function stripToolMetaPreamble(text: string): string { let next = text.trim(); for (let pass = 0; pass < 3; pass += 1) { const stripped = next.replace(TOOL_META_PREAMBLE, "").trim(); if (stripped === next) break; next = stripped; } if (TRAILING_TOOL_META_LINE.test(next)) return ""; return next; } function parseProseToolObject(raw: string): ProseToolCall | null { try { const parsed = JSON.parse(raw) as { name?: unknown; parameters?: unknown; arguments?: unknown; }; if (typeof parsed.name !== "string" || !parsed.name.trim()) return null; const params = parsed.parameters ?? parsed.arguments; if (!params || typeof params !== "object" || Array.isArray(params)) return null; return { name: parsed.name.trim(), parameters: params as Record }; } catch { return null; } } /** Find `{ "name": "tool", "parameters": … }` blobs the model typed as prose. */ export function findProseToolCalls( text: string, ): Array<{ start: number; end: number; call: ProseToolCall }> { const found: Array<{ start: number; end: number; call: ProseToolCall }> = []; const marker = /"name"\s*:\s*"([a-z_][a-z0-9_]*)"/gi; for (let match = marker.exec(text); match; match = marker.exec(text)) { let start = match.index; while (start > 0 && text[start] !== "{") start -= 1; if (text[start] !== "{") continue; let depth = 0; let end = start; for (; end < text.length; end += 1) { const char = text[end]; if (char === "{") depth += 1; else if (char === "}") { depth -= 1; if (depth === 0) { end += 1; break; } } } if (depth !== 0) continue; const call = parseProseToolObject(text.slice(start, end)); if (!call) continue; if (found.some((item) => item.start === start)) continue; found.push({ start, end, call }); } return found.sort((a, b) => a.start - b.start); } export function stripProseToolCalls(text: string): { text: string; proseToolCalls: ProseToolCall[]; } { const matches = findProseToolCalls(text); if (matches.length === 0) return { text, proseToolCalls: [] }; let cleaned = text; for (const match of [...matches].reverse()) { cleaned = `${cleaned.slice(0, match.start)}${cleaned.slice(match.end)}`; } cleaned = stripToolMetaPreamble(cleaned.replace(/\n{3,}/g, "\n\n").trim()); return { text: cleaned, proseToolCalls: matches.map((item) => item.call) }; } /** Prepare bot text for transcript, notifications, and TTS. */ export function sanitizeBotReplyText(text: string): SanitizedBotReply { const strippedRolePrefix = ASSISTANT_ROLE_PREFIX.test(text); const withoutRole = stripAssistantRolePrefix(text); const { text: withoutTools, proseToolCalls } = stripProseToolCalls(withoutRole); return { text: withoutTools.trim(), proseToolCalls, strippedRolePrefix }; } export function isUnspeakableDelegationTool(name: string): boolean { return UNSPEAKABLE_DELEGATION_TOOLS.has(name); } /** True when the whole message is only unspeakable tool prose. */ export function isOnlyUnspeakableToolProse(text: string): boolean { const { text: cleaned, proseToolCalls } = sanitizeBotReplyText(text); return cleaned.length === 0 && proseToolCalls.length > 0; }