import type { ParsedCall, ToolPlan } from './types.ts';
/** Hard delimiters. Single-line is preferred; multi-line is tolerated. */
export const CALL_OPEN = '';
export const CALL_CLOSE = '';
/** Find every … span in a complete text. */
export function findCallSpans(text: string): Array<{ start: number; end: number; body: string }> {
const out: Array<{ start: number; end: number; body: string }> = [];
let i = 0;
while (true) {
const open = text.indexOf(CALL_OPEN, i);
if (open === -1) break;
const bodyStart = open + CALL_OPEN.length;
const close = text.indexOf(CALL_CLOSE, bodyStart);
if (close === -1) break;
out.push({ start: open, end: close + CALL_CLOSE.length, body: text.slice(bodyStart, close) });
i = close + CALL_CLOSE.length;
}
return out;
}
/** Parse the full text against a tool plan; returns parsed calls. Throws on unknown tool. */
export function parseCalls(text: string, plans: ToolPlan[]): ParsedCall[] {
const planByName = new Map(plans.map(p => [p.name, p]));
const calls: ParsedCall[] = [];
for (const span of findCallSpans(text)) {
const { toolName, argsBody } = splitNameAndBody(span.body);
const plan = planByName.get(toolName);
if (!plan) {
throw new ToolReduceParseError(
`Unknown tool "${toolName}". Known: ${[...planByName.keys()].join(', ') || '(none)'}`,
{ toolName, body: span.body },
);
}
const input = encodeArgs(argsBody, plan);
calls.push({ toolName, input, start: span.start, end: span.end });
}
return calls;
}
/** Custom error so the middleware can surface clean messages back to the model. */
export class ToolReduceParseError extends Error {
details: { toolName?: string; body?: string };
constructor(msg: string, details: { toolName?: string; body?: string } = {}) {
super(msg);
this.name = 'ToolReduceParseError';
this.details = details;
}
}
/** Pull `tool_name` off the front of the body, return the remaining args portion. */
export function splitNameAndBody(body: string): { toolName: string; argsBody: string } {
let i = 0;
while (i < body.length && /\s/.test(body[i]!)) i++;
const nameStart = i;
while (i < body.length && !/\s/.test(body[i]!)) i++;
const toolName = body.slice(nameStart, i);
const argsBody = body.slice(i).trim();
return { toolName, argsBody };
}
/** Convert a free-form args body to JSON-stringified args matching the tool schema. */
export function encodeArgs(argsBody: string, plan: ToolPlan): string {
if (plan.encoding === 'json') {
return parseJsonBody(argsBody);
}
const flat = parseWireBody(argsBody, plan);
// Check if any plan fields use dot paths (nested flattening).
const hasDotPaths = plan.fields.some(f => f.name.includes('.'));
if (hasDotPaths) {
return JSON.stringify(reconstructNested(flat));
}
return JSON.stringify(flat);
}
function parseJsonBody(body: string): string {
if (!body) return '{}';
// Auto-detect whether it's a JSON object body.
const trimmed = body.trim();
if (!trimmed.startsWith('{')) {
throw new ToolReduceParseError(
`Expected a JSON object body for json-encoded tool, got: ${trimmed.slice(0, 60)}`,
);
}
try {
const parsed = JSON.parse(trimmed);
return JSON.stringify(parsed);
} catch (err) {
throw new ToolReduceParseError(
`Invalid JSON in tool call body: ${(err as Error).message}`,
);
}
}
/** key=value pairs; values may be quoted with " or '. */
export function parseWireBody(body: string, plan: ToolPlan): Record {
const out: Record = {};
if (!body) return out;
const tokens = tokenizeWire(body);
for (const tok of tokens) {
const eq = tok.indexOf('=');
if (eq === -1) {
throw new ToolReduceParseError(
`Expected key=value, got "${tok}" in tool "${plan.name}"`,
{ toolName: plan.name, body },
);
}
const key = tok.slice(0, eq);
const rawVal = tok.slice(eq + 1);
const field = plan.fields.find(f => f.name === key);
out[key] = coerceValue(rawVal, field?.type);
}
return out;
}
/**
* Reconstruct a nested object from flat dot-path keys.
* E.g. {"profile.displayName": "Alice", "profile.bio": "Engineer"}
* → {"profile": {"displayName": "Alice", "bio": "Engineer"}}
*/
export function reconstructNested(flat: Record): Record {
const out: Record = {};
for (const [key, val] of Object.entries(flat)) {
const parts = key.split('.');
if (parts.length === 1) {
out[key] = val;
} else {
let current = out;
for (let i = 0; i < parts.length; i++) {
const part = parts[i]!;
if (i === parts.length - 1) {
current[part] = val;
} else {
if (!(part in current) || typeof current[part] !== 'object' || current[part] === null) {
current[part] = {};
}
current = current[part] as Record;
}
}
}
}
return out;
}
/**
* Whitespace-delimited tokenizer. Splits on whitespace except inside matched quotes.
* A token may contain an `=` followed by a quoted string. Backslash escapes
* inside quotes are honored: \", \\, \n, \t.
*/
export function tokenizeWire(input: string): string[] {
const out: string[] = [];
let i = 0;
let cur = '';
while (i < input.length) {
const ch = input[i]!;
if (/\s/.test(ch)) {
if (cur.length) {
out.push(cur);
cur = '';
}
i++;
continue;
}
if (ch === '"' || ch === "'") {
const quote = ch;
cur += ch; // keep quote in token; consumed below in coerceValue
i++;
while (i < input.length) {
const c2 = input[i]!;
if (c2 === '\\' && i + 1 < input.length) {
cur += c2 + input[i + 1]!;
i += 2;
continue;
}
cur += c2;
i++;
if (c2 === quote) break;
}
continue;
}
cur += ch;
i++;
}
if (cur.length) out.push(cur);
return out;
}
/** Coerce a raw string value into the JS type implied by the schema field type label. */
export function coerceValue(raw: string, type: string | undefined): unknown {
// Quoted strings (double or single).
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
return unquote(raw);
}
// Array literals: ["a","b"]
if (raw.startsWith('[') && raw.endsWith(']')) {
try {
return JSON.parse(raw);
} catch {
// Fall through if JSON parse fails — treat as string
}
}
if (type === 'string') return raw;
// Booleans and null
if (raw === 'true') return true;
if (raw === 'false') return false;
if (raw === 'null') return null;
// Numbers
if (type === 'number' || type === 'int') {
const n = Number(raw);
if (Number.isFinite(n)) return type === 'int' ? Math.trunc(n) : n;
} else {
// Untyped: best-effort numeric coercion only if it round-trips.
if (raw.length > 0 && /^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
}
// String-enum or fallthrough
return raw;
}
function unquote(s: string): string {
const inner = s.slice(1, -1);
return inner.replace(/\\(["'\\nrt])/g, (_, c: string) => {
switch (c) {
case 'n':
return '\n';
case 'r':
return '\r';
case 't':
return '\t';
default:
return c;
}
});
}