evOo-local/packages/core/src/bot-reply-sanitize.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

130 lines
4.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** 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<string, unknown>;
};
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<string, unknown> };
} 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;
}