// core/tools.js — the agentic TOOL-CALLING loop over the local engine + MCP. Qwen2.5's
// native function-calling convention: the system turn declares tools as JSON schemas inside
// a … block; the model emits {"name":…,"arguments":…};
// each result returns as a user-role … turn and generation
// continues — until a turn arrives with no tool call (the final answer) or maxRounds hits.
//
// Substrate-native: EVERY tool call is sealed as its own PROV-O activity (tool ⊕ args ⊕
// result, each by κ) and the per-call receipts ride the answer's receipt — a verifiable
// work-trail for agentic answers, the chat-layer analogue of Holo Orchestrate (ADR-045).
import { didHolo, kappaText, jcs } from "./kappa.js";
// The system turn that arms the model with tools (Qwen2.5 function-calling convention).
export function toolSystemPrompt(tools, extra = "") {
const decls = tools.map((t) => JSON.stringify({ type: "function", function: { name: t.name, description: t.description || "", parameters: t.inputSchema || { type: "object", properties: {} } } })).join("\n");
return (
(extra ? extra + "\n\n" : "") +
"# Tools\n\nYou may call one or more functions to assist with the user query.\n\n" +
"You are provided with function signatures within XML tags:\n\n" + decls + "\n\n\n" +
"For each function call, return a json object with function name and arguments within XML tags:\n" +
'\n{"name": , "arguments": }\n'
);
}
// Parse blocks out of a model turn. Tolerant: bare JSON with name+arguments
// also counts (small models sometimes drop the tags).
export function parseToolCalls(text) {
const calls = [];
const re = /\s*([\s\S]*?)\s*<\/tool_call>/g;
let m;
while ((m = re.exec(text))) { try { const j = JSON.parse(m[1]); if (j && j.name) calls.push({ name: j.name, arguments: j.arguments || {} }); } catch {} }
// ```json fenced code blocks with {name, arguments} (Qwen2.5-Coder often emits this form)
if (!calls.length) {
const cb = /```(?:json|tool_call)?\s*(\{[\s\S]*?\})\s*```/g;
while ((m = cb.exec(text))) { try { const j = JSON.parse(m[1]); if (j && j.name && ("arguments" in j)) calls.push({ name: j.name, arguments: j.arguments || {} }); } catch {} }
}
if (!calls.length) {
const t = text.trim();
if (t.startsWith("{") && t.includes('"name"')) { try { const j = JSON.parse(t); if (j.name && ("arguments" in j)) calls.push({ name: j.name, arguments: j.arguments || {} }); } catch {} }
}
return calls;
}
export const stripToolCalls = (text) => text
.replace(/[\s\S]*?<\/tool_call>/g, "")
.replace(/```(?:json|tool_call)?\s*\{[\s\S]*?"name"[\s\S]*?\}\s*```/g, "") // strip fenced tool calls too
.trim();
// Seal one tool call as a PROV-O activity (its own did:holo — tamper → refuse).
export async function sealToolReceipt({ server, tool, args, resultText, ok }) {
const body = {
"@context": ["http://www.w3.org/ns/prov#", { holo: "https://hologram.os/ns/q#" }],
"@type": "prov:Activity", "holo:kind": "tool-call",
"prov:used": { "holo:server": server, "holo:tool": tool, "holo:args": await didHolo(JSON.parse(jcs(args || {}))), "holo:argsJson": jcs(args || {}) },
"prov:generated": { "holo:result": await kappaText(resultText), "holo:ok": !!ok },
};
return { id: await didHolo(body), body };
}
// Frame the agentic first turn: system(tools) + user prompt (ChatML, by hand so the system
// block rides this turn). Exposed so the caller can persist the EXACT ids that run (the
// ctx-concat invariant of the message tree).
export function frameAgenticTurn({ tools, promptText, hasHistory, extraSystem = "" }) {
const sys = toolSystemPrompt(tools.map((t) => t.def), extraSystem);
return (hasHistory ? "<|im_end|>\n" : "") +
`<|im_start|>system\n${sys}<|im_end|>\n<|im_start|>user\n${promptText}<|im_end|>\n<|im_start|>assistant\n`;
}
// The loop. `callbacks.onToolCall({name,args})` / `onToolResult({name,text,receipt})` let the
// UI stream the work-trail live. Returns { text, rounds, toolReceipts, trace, ids }.
export async function runToolLoop({ engine, tools, promptText, ctxIds, firstFramed, signal, onToken, onToolCall, onToolResult, maxRounds = 4 }) {
const toolReceipts = []; const trace = [];
let framed = firstFramed || frameAgenticTurn({ tools, promptText, hasHistory: ctxIds.length > 0 });
let ids = ctxIds.slice();
let finalText = "", rounds = 0;
for (; rounds < maxRounds; rounds++) {
const turnIds = engine.tokenize(framed);
ids = ids.concat(turnIds);
// tight per-round budget: a tool_call is ~50 tokens, a final answer ~300 — snappy rounds,
// the same total work, far lower latency than letting one round run the whole cap.
// generous budget: tool calls that carry CODE (write_file, build_app) need room — a truncated
// call is invalid JSON and silently fails. 1536 fits a small app; plain rounds end early on EOS.
const res = await engine.generate(ids, { signal, onToken, maxNew: 1536 });
ids = res.ids;
const calls = parseToolCalls(res.text);
if (!calls.length || (signal && signal.aborted)) { finalText = stripToolCalls(res.text); break; }
// execute every call this round through MCP, seal a receipt each
const responses = [];
for (const c of calls) {
if (signal && signal.aborted) break;
const t = tools.find((x) => x.def.name === c.name);
onToolCall && onToolCall({ name: c.name, args: c.arguments, server: t ? t.serverName : "?" });
let text, ok = true, render = null;
try {
if (!t) throw new Error("unknown tool: " + c.name);
const r = await t.call(c.arguments);
text = r.text; ok = !r.isError; render = r.render || null; // build_app → live preview payload
} catch (e) { text = "ERROR: " + (e && e.message || e); ok = false; }
const receipt = await sealToolReceipt({ server: t ? t.serverName : "?", tool: c.name, args: c.arguments, resultText: text, ok });
toolReceipts.push(receipt);
trace.push({ name: c.name, args: c.arguments, text: text.slice(0, 4000), ok, receiptId: receipt.id, server: t ? t.serverName : "?" });
onToolResult && onToolResult({ name: c.name, text, ok, receipt, render });
responses.push(`\n${text.slice(0, 2400)}\n`); // bounded — tool output must fit the local context window
}
// feed results back (user role per the Qwen convention) and continue
framed = `<|im_end|>\n<|im_start|>user\n${responses.join("\n")}<|im_end|>\n<|im_start|>assistant\n`;
finalText = stripToolCalls(res.text); // best-so-far, replaced if a later round answers
}
return { text: finalText, rounds: rounds + 1, toolReceipts, trace, ids };
}