// A minimal local tool-calling loop against Ollama's /api/chat, which // genuinely supports OpenAI-style function-calling for tool-capable // models (verified directly against qwen2.5:14b-instruct before building // this). No Claude/Anthropic API, no BYOK -- local model only, per the // project's current scope. import type { ToolDef } from "./tools.js"; interface ChatMessage { role: "system" | "user" | "assistant" | "tool"; content: string; tool_calls?: { function: { name: string; arguments: Record } }[]; } function toOllamaTools(tools: ToolDef[]) { return tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } })); } // Full (pretty-printed, multi-line) rather than a one-line truncated // summary -- the activity log is meant to show what the model is // actually doing (the real diffs/content in a commit_changeset or // ladder_write_rung call, not just its shape), the same way Claude // Code's own transcript shows full tool calls, not a gist of them. The // cap is generous, not absent, so one pathological payload can't lock // up the log. function formatForLog(value: unknown): string { if (typeof value === "string") return value; const json = JSON.stringify(value, null, 2); if (!json) return String(value); return json.length > 6000 ? json.slice(0, 5997) + "..." : json; } // Some models (observed with qwen3-coder:30b) serialize a complex // argument -- an object or array meant to be nested JSON -- as a JSON // string instead of passing it as a native nested value. Parse any // top-level string argument that looks like JSON before it reaches the // tool, rather than letting every tool guess at this independently. function coerceJsonStringArgs(args: Record): Record { const out: Record = {}; for (const [key, value] of Object.entries(args)) { if (typeof value === "string" && (value.startsWith("{") || value.startsWith("["))) { try { out[key] = JSON.parse(value); continue; } catch { // not actually JSON; fall through and keep the raw string } } out[key] = value; } return out; } export interface RunAgentOptions { systemPrompt: string; userMessage: string; tools: ToolDef[]; maxTurns?: number; model?: string; baseUrl?: string; /** Called with each log line instead of `console.error`, so a TUI can capture the agent's activity instead of it scrolling over the rendered screen. Defaults to `console.error`. */ onLog?: (line: string) => void; /** * Checked whenever the model stops calling tools, before trusting * that as completion -- observed repeatedly: a model describes a * plan in prose ("Now I'll implement X and test it") and then simply * stops without ever calling a tool, which used to be read as * "finished" even though nothing happened. Defaults to always true * (trust the stop) for callers that don't have a concrete way to * check; callers that do (e.g. "is the ask I was handed actually * answered yet") should supply a real one. */ isActuallyDone?: () => boolean; } export interface RunAgentResult { turns: number; finished: boolean; finalMessage: string; } const MAX_GIVE_UP_NUDGES = 2; export async function runAgent(opts: RunAgentOptions): Promise { const baseUrl = opts.baseUrl ?? process.env.ZZ_OLLAMA_HOST ?? "http://localhost:11434"; const model = opts.model ?? process.env.ZZ_OLLAMA_MODEL ?? "qwen2.5:14b-instruct"; const maxTurns = opts.maxTurns ?? 40; const log = opts.onLog ?? ((line: string) => console.error(line)); const byName = new Map(opts.tools.map((t) => [t.name, t])); const messages: ChatMessage[] = [ { role: "system", content: opts.systemPrompt }, { role: "user", content: opts.userMessage }, ]; let lastError: { tool: string; message: string } | null = null; let nudgesUsed = 0; const isActuallyDone = opts.isActuallyDone ?? (() => true); for (let turn = 1; turn <= maxTurns; turn++) { const res = await fetch(`${baseUrl}/api/chat`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model, messages, tools: toOllamaTools(opts.tools), stream: false }), }); if (!res.ok) throw new Error(`ollama chat failed: ${res.status} ${await res.text()}`); const data = (await res.json()) as { message: ChatMessage }; const { message } = data; messages.push(message); if (message.content && message.content.trim()) { log(`[turn ${turn}] says: ${message.content.trim()}`); } if (!message.tool_calls || message.tool_calls.length === 0) { // Stopping doesn't mean finished -- two real failure modes this // project has actually hit, both caught by the same check (is // whatever the caller cares about actually done?) rather than // trusting silence as completion: (1) stopping right after an // unresolved tool error, the model having given up instead of // retrying; (2) describing a plan in prose ("I'll implement X and // test it") and then simply stopping without ever calling a tool // to do it. Nudge either way, up to a small bounded number of // times so a genuinely stuck agent still ends. if (!isActuallyDone() && nudgesUsed < MAX_GIVE_UP_NUDGES) { nudgesUsed += 1; // Naming the specific failing tool and its exact error combats // a real observed failure mode: the model blaming a *different*, // already-succeeded call for an error that actually came from // the one right before it stopped. const reason = lastError ? `Your last tool call (${lastError.tool}) failed with: ${lastError.message} -- fix that specific call (check which arguments it actually needs) and retry it, or if you genuinely can't complete the work, call ask_answer with action "deny" and explain why. Don't just stop without one of those.` : `You described what you'd do but didn't actually call the tools to do it -- describing a plan isn't the same as executing it, and the work isn't done yet. Call the tools now to actually make the change/test/submit, then ask_answer once it's genuinely done.`; log(`[turn ${turn}] (stopped without finishing -- nudging instead of ending, ${nudgesUsed}/${MAX_GIVE_UP_NUDGES})`); messages.push({ role: "user", content: reason }); continue; } log(`[turn ${turn}] (no tool calls -- agent considers itself done)`); return { turns: turn, finished: true, finalMessage: message.content }; } lastError = null; for (const call of message.tool_calls) { const tool = byName.get(call.function.name); log(`[turn ${turn}] -> ${call.function.name}(\n${formatForLog(call.function.arguments)}\n)`); let result: unknown; if (!tool) { result = { error: `no such tool: ${call.function.name}` }; } else { try { result = await tool.execute(coerceJsonStringArgs(call.function.arguments)); } catch (err) { const resolved = err instanceof Error && (err as { resolved?: boolean }).resolved === true; result = { error: err instanceof Error ? err.message : String(err), ...(resolved ? { resolved: true } : {}) }; } } if (result && typeof result === "object" && "error" in result) { lastError = { tool: call.function.name, message: String((result as { error: unknown }).error) }; } log(`[turn ${turn}] <- ${call.function.name} result:\n${formatForLog(result)}`); // A structural "this work is already resolved" signal (see // engine/lane.ts's duplicate-answer guard) ends the loop here, // regardless of what the model does next -- observed repeatedly: // a model that retries this exact call despite the error text // keeps retrying it rather than stopping on its own. if (result && typeof result === "object" && (result as { resolved?: boolean }).resolved === true) { log(`[turn ${turn}] (already resolved -- ending here instead of letting the model keep retrying)`); return { turns: turn, finished: true, finalMessage: "" }; } messages.push({ role: "tool", content: JSON.stringify(result) }); } } log(`stopped after ${maxTurns} turns without the agent finishing on its own.`); return { turns: maxTurns, finished: false, finalMessage: "" }; }