Spaces:
Runtime error
Runtime error
File size: 10,950 Bytes
077865a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 | /**
* Inline tool-call dialect rescue (#231 audit).
*
* When a conversation switches models mid-task (failover, sticky miss), the
* new model often continues the previous model's tool-call style and emits
* the call as TEXT in its private training dialect instead of a structured
* `tool_calls` array. The client's agent loop sees prose, treats the turn as
* a final answer, and dies mid-task β observed live with the OpenAI Agents
* SDK when Kimi-K2.6 continued a DeepSeek history:
*
* <|tool_calls_section_begin|> <|tool_call_begin|> chatcmpl-tool-bde5...
*
* This module detects the known dialects and re-parses them into standard
* OpenAI tool_calls, schema-gated against the request's tool list. A turn
* that is detected as a dialect but cannot be parsed into a known tool is a
* DEAD turn β the caller fails over instead of delivering gibberish.
*
* Supported dialects:
* 1. Kimi / DeepSeek token style:
* <|tool_calls_section_begin|><|tool_call_begin|>functions.NAME:0
* <|tool_call_argument_begin|>{...}<|tool_call_end|>...
* 2. Llama / Groq function tags: <function=NAME{...}</function> and
* <function=NAME>{...}</function>
* 3. Qwen / Hermes XML: <tool_call>{"name": ..., "arguments": ...}</tool_call>
* 4. Bare or ```json-fenced single JSON object: {"name": KNOWN, "arguments": {...}}
* (only rescued when "name" matches a requested tool β bare JSON is a
* legitimate answer shape, so this one is strictly schema-gated)
*/
export interface RescuedToolCall {
name: string;
/** JSON string, exactly like OpenAI's function.arguments */
arguments: string;
}
export interface RescueResult {
/** True when the text contains inline tool-call dialect markers. */
detected: boolean;
/** Parsed calls; null when detected but unparseable (dead turn). */
calls: RescuedToolCall[] | null;
/** Text with the dialect blocks removed (may be ''). */
cleanText: string;
}
// Markers that begin an inline dialect block. Used both for full-text
// detection and for the streaming hold-window decision in proxy.ts.
const DIALECT_MARKERS = [
'<|tool_calls_section_begin|>',
'<|tool_call_begin|>',
'<tool_call>',
'<function=',
] as const;
/** Does the (trimmed) text start with a known dialect marker? */
export function startsWithDialectMarker(text: string): boolean {
const t = text.trimStart();
return DIALECT_MARKERS.some(m => t.startsWith(m));
}
/**
* Streaming hold-window helper: could `text` still grow into a dialect
* marker? True while text is a strict prefix of some marker (e.g. "<|too"),
* so the stream loop keeps holding; once this and startsWithDialectMarker
* are both false the text is ordinary prose and can be flushed.
*/
export function couldBecomeDialectMarker(text: string): boolean {
const t = text.trimStart();
if (t.length === 0) return true;
return DIALECT_MARKERS.some(m => m.startsWith(t) && t.length < m.length);
}
/** Anywhere-in-text detection for the non-streaming path. */
export function containsDialectMarker(text: string): boolean {
return DIALECT_MARKERS.some(m => text.includes(m));
}
/**
* Extract one balanced JSON object or array starting at text[from] (which
* must be '{' or '['). Returns the slice and the index after it, or null.
* String-aware so braces inside JSON strings don't break the balance.
*/
function extractBalancedJson(text: string, from: number): { json: string; end: number } | null {
const open = text[from];
if (open !== '{' && open !== '[') return null;
const close = open === '{' ? '}' : ']';
let depth = 0;
let inString = false;
let escaped = false;
for (let i = from; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === open) depth++;
else if (ch === close) {
depth--;
if (depth === 0) return { json: text.slice(from, i + 1), end: i + 1 };
}
}
return null;
}
const isKnownTool = (name: string, toolNames: Set<string>): boolean =>
toolNames.size === 0 || toolNames.has(name);
/** Parse `{"name": ..., "arguments"|"parameters": ...}` into a call. */
function callFromNamedJson(json: string, toolNames: Set<string>): RescuedToolCall | null {
let obj: unknown;
try { obj = JSON.parse(json); } catch { return null; }
if (typeof obj !== 'object' || obj === null) return null;
const o = obj as Record<string, unknown>;
const name = typeof o.name === 'string' ? o.name : undefined;
if (!name || !isKnownTool(name, toolNames)) return null;
const rawArgs = o.arguments ?? o.parameters ?? {};
const args = typeof rawArgs === 'string' ? rawArgs : JSON.stringify(rawArgs);
try { JSON.parse(args); } catch { return null; }
return { name, arguments: args };
}
/** Dialect 1: Kimi/DeepSeek <|tool_call_begin|> token blocks. */
function parseTokenDialect(text: string, toolNames: Set<string>): { calls: RescuedToolCall[] | null; cleanText: string } {
const calls: RescuedToolCall[] = [];
let clean = text;
// Strip section wrappers first; they carry no information.
clean = clean.replaceAll('<|tool_calls_section_begin|>', '').replaceAll('<|tool_calls_section_end|>', '');
const callRe = /<\|tool_call_begin\|>\s*([\s\S]*?)\s*<\|tool_call_argument_begin\|>\s*/g;
let m: RegExpExecArray | null;
let parsedAll = true;
const spans: Array<{ from: number; to: number }> = [];
while ((m = callRe.exec(clean)) !== null) {
const idToken = m[1].trim();
const argStart = m.index + m[0].length;
const jsonStart = clean.indexOf('{', argStart);
const extracted = jsonStart === -1 ? null : extractBalancedJson(clean, jsonStart);
// Function name rides in the id token as `functions.NAME:IDX`. Some
// models degrade it to an opaque id (observed: `chatcmpl-tool-<hex>`),
// which leaves no way to know WHICH tool was meant β unparseable.
const nameMatch = /^functions\.([A-Za-z0-9_.-]+):\d+$/.exec(idToken);
const name = nameMatch?.[1];
let argsOk = false;
if (extracted && name && isKnownTool(name, toolNames)) {
try { JSON.parse(extracted.json); argsOk = true; } catch { /* fall through */ }
if (argsOk) calls.push({ name, arguments: extracted.json });
}
if (!argsOk) parsedAll = false;
const endTag = clean.indexOf('<|tool_call_end|>', extracted?.end ?? argStart);
spans.push({ from: m.index, to: endTag === -1 ? (extracted?.end ?? argStart) : endTag + '<|tool_call_end|>'.length });
}
for (const s of [...spans].reverse()) clean = clean.slice(0, s.from) + clean.slice(s.to);
return { calls: parsedAll && calls.length > 0 ? calls : null, cleanText: clean.trim() };
}
/** Dialect 2: <function=NAME{...}</function> (with or without a '>' after the name). */
function parseFunctionTagDialect(text: string, toolNames: Set<string>): { calls: RescuedToolCall[] | null; cleanText: string } {
const calls: RescuedToolCall[] = [];
let clean = text;
let parsedAll = true;
const headRe = /<function=([A-Za-z0-9_.-]+)\s*>?\s*/g;
let m: RegExpExecArray | null;
const spans: Array<{ from: number; to: number }> = [];
while ((m = headRe.exec(text)) !== null) {
const name = m[1];
const afterHead = m.index + m[0].length;
const jsonStart = text[afterHead] === '{' || text[afterHead] === '['
? afterHead
: text.indexOf('{', afterHead);
const extracted = jsonStart === -1 ? null : extractBalancedJson(text, jsonStart);
let ok = false;
if (extracted && isKnownTool(name, toolNames) && extracted.json.startsWith('{')) {
try { JSON.parse(extracted.json); ok = true; } catch { /* fall through */ }
if (ok) calls.push({ name, arguments: extracted.json });
}
if (!ok) parsedAll = false; // array-shaped or invalid args: not a callable shape
const closeTag = text.indexOf('</function>', extracted?.end ?? m.index + m[0].length);
spans.push({ from: m.index, to: closeTag === -1 ? (extracted?.end ?? m.index + m[0].length) : closeTag + '</function>'.length });
}
for (const s of [...spans].reverse()) clean = clean.slice(0, s.from) + clean.slice(s.to);
return { calls: parsedAll && calls.length > 0 ? calls : null, cleanText: clean.trim() };
}
/** Dialect 3: <tool_call>{...}</tool_call> XML-JSON blocks. */
function parseXmlDialect(text: string, toolNames: Set<string>): { calls: RescuedToolCall[] | null; cleanText: string } {
const calls: RescuedToolCall[] = [];
let parsedAll = true;
const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
let m: RegExpExecArray | null;
let clean = text;
const matches: string[] = [];
while ((m = re.exec(text)) !== null) matches.push(m[1]);
for (const inner of matches) {
const call = callFromNamedJson(inner, toolNames);
if (call) calls.push(call);
else parsedAll = false;
}
clean = clean.replace(re, '');
// An opening tag with no close (truncated stream) is detected-but-broken.
if (/<tool_call>/.test(clean)) { parsedAll = false; clean = clean.replace(/<tool_call>[\s\S]*$/, ''); }
return { calls: parsedAll && calls.length > 0 ? calls : null, cleanText: clean.trim() };
}
/**
* Rescue inline tool-call dialects out of an assistant text answer.
*
* @param text the assistant message content
* @param toolNames the names of the tools the REQUEST declared; rescued
* calls must match one (empty set = accept any name,
* used by tests only)
*/
export function rescueInlineToolCalls(text: string, toolNames: Set<string>): RescueResult {
if (!text) return { detected: false, calls: null, cleanText: text };
if (text.includes('<|tool_call_begin|>') || text.includes('<|tool_calls_section_begin|>')) {
const { calls, cleanText } = parseTokenDialect(text, toolNames);
return { detected: true, calls, cleanText };
}
if (text.includes('<function=')) {
const { calls, cleanText } = parseFunctionTagDialect(text, toolNames);
return { detected: true, calls, cleanText };
}
if (text.includes('<tool_call>')) {
const { calls, cleanText } = parseXmlDialect(text, toolNames);
return { detected: true, calls, cleanText };
}
// Dialect 4: the entire answer is one JSON object naming a known tool β
// either bare or inside a ```json fence. Strictly schema-gated.
const trimmed = text.trim();
const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/.exec(trimmed);
const candidate = (fenced ? fenced[1] : trimmed).trim();
if (candidate.startsWith('{') && candidate.endsWith('}')) {
const call = callFromNamedJson(candidate, toolNames);
// Only treat as dialect when it actually names a requested tool;
// arbitrary JSON answers must pass through untouched.
if (call) return { detected: true, calls: [call], cleanText: '' };
}
return { detected: false, calls: null, cleanText: text };
}
|