Bonsai-Chat-WebGPU / src /lib /tool-protocol.ts
Valeriy Selitskiy
Release v1.2.2 interactive chat workspace
21ad36a
Raw
History Blame Contribute Delete
4.57 kB
export interface ParsedToolCall {
name: string;
arguments: Record<string, unknown>;
raw: string;
}
export interface ToolCallParseResult {
calls: ParsedToolCall[];
content: string;
incomplete: boolean;
errors: string[];
}
const OPEN = '<tool_call>';
const CLOSE = '</tool_call>';
const FUNCTION_CLOSE = '</function>';
const PARAMETER_CLOSE = '</parameter>';
const MAX_TOOL_PAYLOAD = 64 * 1024;
const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.:-]*$/;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseJsonEnvelope(raw: string): ParsedToolCall {
const envelope: unknown = JSON.parse(raw);
if (!isRecord(envelope) || typeof envelope.name !== 'string' || !envelope.name.trim()) {
throw new Error('tool call must contain a non-empty name');
}
let argumentsValue = envelope.arguments;
if (typeof argumentsValue === 'string') argumentsValue = JSON.parse(argumentsValue);
if (!isRecord(argumentsValue)) throw new Error('tool call arguments must be an object');
return { name: envelope.name.trim(), arguments: argumentsValue, raw };
}
function parseFunctionEnvelope(raw: string): ParsedToolCall {
const functionOpeningEnd = raw.indexOf('>');
if (!raw.startsWith('<function=') || functionOpeningEnd < 0) {
throw new Error('tool call must contain a JSON or function envelope');
}
const name = raw.slice('<function='.length, functionOpeningEnd).trim();
if (!NAME_PATTERN.test(name)) throw new Error('tool call must contain a valid function name');
const functionClosing = raw.lastIndexOf(FUNCTION_CLOSE);
if (functionClosing < functionOpeningEnd || raw.slice(functionClosing + FUNCTION_CLOSE.length).trim()) {
throw new Error('function tool call is missing its closing tag');
}
const body = raw.slice(functionOpeningEnd + 1, functionClosing);
const argumentsValue: Record<string, unknown> = {};
let cursor = 0;
while (cursor < body.length) {
while (/\s/u.test(body[cursor] ?? '')) cursor += 1;
if (cursor >= body.length) break;
if (!body.startsWith('<parameter=', cursor)) {
throw new Error('function tool call contains text outside a parameter');
}
const parameterOpeningEnd = body.indexOf('>', cursor);
if (parameterOpeningEnd < 0) throw new Error('function tool call has an incomplete parameter tag');
const parameterName = body.slice(cursor + '<parameter='.length, parameterOpeningEnd).trim();
if (!NAME_PATTERN.test(parameterName)) throw new Error('function tool call contains an invalid parameter name');
if (Object.hasOwn(argumentsValue, parameterName)) throw new Error(`function tool call repeats parameter ${parameterName}`);
const valueStart = parameterOpeningEnd + 1;
const valueEnd = body.indexOf(PARAMETER_CLOSE, valueStart);
if (valueEnd < 0) throw new Error(`function tool call is missing the closing tag for parameter ${parameterName}`);
argumentsValue[parameterName] = body.slice(valueStart, valueEnd).trim();
cursor = valueEnd + PARAMETER_CLOSE.length;
}
return { name, arguments: argumentsValue, raw };
}
function parseEnvelope(raw: string): ParsedToolCall {
if (raw.length > MAX_TOOL_PAYLOAD) throw new Error('tool call exceeds 64 KiB');
return raw.startsWith('<function=') ? parseFunctionEnvelope(raw) : parseJsonEnvelope(raw);
}
export function parseToolCalls(value: string): ToolCallParseResult {
const calls: ParsedToolCall[] = [];
const errors: string[] = [];
const content: string[] = [];
let cursor = 0;
let incomplete = false;
while (cursor < value.length) {
const opening = value.indexOf(OPEN, cursor);
if (opening < 0) {
content.push(value.slice(cursor));
break;
}
content.push(value.slice(cursor, opening));
const payloadStart = opening + OPEN.length;
const closing = value.indexOf(CLOSE, payloadStart);
if (closing < 0) {
incomplete = true;
break;
}
const raw = value.slice(payloadStart, closing).trim();
try {
calls.push(parseEnvelope(raw));
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error));
}
cursor = closing + CLOSE.length;
}
return {
calls,
content: content.join('').trim(),
incomplete,
errors,
};
}
export function renderToolResponse(value: unknown): string {
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
return `<tool_response>\n${serialized.slice(0, MAX_TOOL_PAYLOAD)}\n</tool_response>`;
}