Forked from ceveyne/draw-things-chat
Project Files
src / thinkingToolCallParser.ts
/**
* Post-Thinking Intercept — Parser
*
* Scans the full accumulated reasoning/thinking text for tool-call blocks
* delimited by configurable markers (default: <tool_call>…</tool_call>).
* Each block is parsed into a structured tool-call that can be replayed via
* the generator controller after the reasoning block closes.
*
* Supported payload formats inside the markers (tried in order):
* 1. XML-parameter style <function=NAME><parameter=KEY>VALUE</parameter></function>
* 2. JSON object {"name": "…", "arguments": {…}}
* 3. JSON in code block ```json\n{…}\n```
*/
export interface ParsedToolCall {
name: string;
arguments: Record<string, unknown>;
/** Raw text between the markers — for logging only. */
rawText: string;
}
const DEFAULT_START_MARKER = "<tool_call>";
const DEFAULT_END_MARKER = "</tool_call>";
// ---------------------------------------------------------------------------
// Payload parsers
// ---------------------------------------------------------------------------
/**
* Format 1 — Hermes/Llama XML-parameter style:
* <function=index_image>
* <parameter=query>
* studio portrait
* </parameter>
* </function>
*/
function parseXmlParameterStyle(text: string): ParsedToolCall | null {
const fnMatch = text.match(/<function=([^\s>]+)\s*>/);
if (!fnMatch) return null;
const name = fnMatch[1].trim();
const args: Record<string, unknown> = {};
// Extract all <parameter=KEY>VALUE</parameter> pairs
const paramRe = /<parameter=([^\s>]+)\s*>\s*([\s\S]*?)\s*<\/parameter>/g;
let m: RegExpExecArray | null;
while ((m = paramRe.exec(text)) !== null) {
const key = m[1].trim();
const rawValue = m[2].trim();
// Try to coerce JSON values (numbers, booleans, objects, arrays)
try {
args[key] = JSON.parse(rawValue);
} catch {
args[key] = rawValue;
}
}
if (!name) return null;
return { name, arguments: args, rawText: text };
}
/**
* Format 2 — Plain JSON object:
* {"name": "…", "arguments": {…}}
* {"function": {"name": "…", "arguments": {…}}} (OpenAI-function-call style)
*/
function parseJsonObject(text: string): ParsedToolCall | null {
const trimmed = text.trim();
if (!trimmed.startsWith("{")) return null;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null) return null;
const obj = parsed as Record<string, unknown>;
// Unwrap optional "function" envelope
const inner = typeof obj["function"] === "object" && obj["function"] !== null
? (obj["function"] as Record<string, unknown>)
: obj;
const name = typeof inner["name"] === "string" ? inner["name"] : null;
if (!name) return null;
let args: Record<string, unknown> = {};
if (typeof inner["arguments"] === "object" && inner["arguments"] !== null) {
args = inner["arguments"] as Record<string, unknown>;
} else if (typeof inner["arguments"] === "string") {
// Double-encoded: "arguments": "{\"query\":\"…\"}"
try {
const decoded = JSON.parse(inner["arguments"]);
if (typeof decoded === "object" && decoded !== null) {
args = decoded as Record<string, unknown>;
}
} catch {
// leave args empty
}
}
return { name, arguments: args, rawText: text };
}
/**
* Format 3 — JSON wrapped in a markdown code block:
* ```json
* {…}
* ```
*/
function parseJsonCodeBlock(text: string): ParsedToolCall | null {
const m = text.match(/```(?:json)?\s*([\s\S]*?)```/);
if (!m) return null;
return parseJsonObject(m[1]);
}
// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------
/**
* Scan `text` for all tool-call blocks delimited by `startMarker`/`endMarker`
* and return structured representations in document order.
*
* Never throws — malformed blocks are skipped with a console warning.
*/
export function parseThinkingToolCalls(
text: string,
startMarker: string = DEFAULT_START_MARKER,
endMarker: string = DEFAULT_END_MARKER,
): ParsedToolCall[] {
const results: ParsedToolCall[] = [];
let searchFrom = 0;
while (true) {
const startIdx = text.indexOf(startMarker, searchFrom);
if (startIdx === -1) break;
const contentStart = startIdx + startMarker.length;
const endIdx = text.indexOf(endMarker, contentStart);
if (endIdx === -1) break; // Unclosed marker — stop scanning
const payload = text.slice(contentStart, endIdx);
searchFrom = endIdx + endMarker.length;
// Try parsers in priority order
let call: ParsedToolCall | null = null;
try {
call =
parseXmlParameterStyle(payload) ??
parseJsonObject(payload) ??
parseJsonCodeBlock(payload);
} catch {
// Should never reach here given each parser catches internally
}
if (call) {
results.push(call);
} else {
const excerpt = payload.slice(0, 120).replace(/\n/g, "↵");
console.warn(
`[PostThinkingIntercept] Failed to parse tool call block: ${excerpt}`,
);
}
}
return results;
}