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.
130 lines
4.3 KiB
TypeScript
130 lines
4.3 KiB
TypeScript
/** 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;
|
||
}
|