zz_

app/agent/loop.ts

zzmq.dev@gmail.com/zz / app / agent / loop.ts @ 69f3dd4b Download
Ladder Summary
  • file This module implements a generic, tool‑calling loop that drives any `ChatBackend` (local Ollama, remote Gemini, etc.) by exchanging `ChatMessage`s, handling JSON‑string coercion, logging, partial‑response streaming, backend retry back‑off, and termination checks. It exports the `runAgent(opts: RunAgentOptions): Promise<RunAgentResult>` function together with the `RunAgentOptions` and `RunAgentResult` TypeScript interfaces that describe the caller’s configuration and the function’s outcome. No classes are exported.
 // 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<string, unknown> } }[];
 }
 
 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<string, unknown>): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  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;
  /**
  * Called repeatedly with the accumulated text-so-far of the current
  * turn's response as it streams in (verified against Ollama's real
  * wire format: prose content arrives token-by-token, tool_calls
  * arrive as one complete chunk once decided -- same total generation
  * time as non-streaming, just delivered incrementally instead of
  * buffered until the end). A TUI wires this to a live status line
  * instead of a log line, so it doesn't spam the scrollback with one
  * entry per token.
  */
  onPartial?: (textSoFar: 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<RunAgentResult> {
  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++) {
  // Logged *before* the request, not just after -- a non-streaming
  // call can legitimately take 10-30+ seconds for a 14B-class local
  // model, and with nothing printed in that window it reads as hung
  // rather than working. Reporting elapsed time after the fact turns
  // "is this stuck?" into a concrete, comparable number across turns.
  log(`[turn ${turn}] waiting for ${model}...`);
  const turnStart = Date.now();
  const res = await fetch(`${baseUrl}/api/chat`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ model, messages, tools: toOllamaTools(opts.tools), stream: true }),
  });
  if (!res.ok) throw new Error(`ollama chat failed: ${res.status} ${await res.text()}`);
  if (!res.body) throw new Error("ollama chat response had no body to stream");
 
  let content = "";
  let toolCalls: ChatMessage["tool_calls"];
  let buffer = "";
  for await (const rawChunk of res.body as unknown as AsyncIterable<Uint8Array>) {
  buffer += Buffer.from(rawChunk).toString("utf8");
  let newlineAt: number;
  while ((newlineAt = buffer.indexOf("\n")) >= 0) {
  const line = buffer.slice(0, newlineAt).trim();
  buffer = buffer.slice(newlineAt + 1);
  if (!line) continue;
  const chunk = JSON.parse(line) as { message?: { content?: string; tool_calls?: ChatMessage["tool_calls"] } };
  if (chunk.message?.content) {
  content += chunk.message.content;
  opts.onPartial?.(content);
  }
  if (chunk.message?.tool_calls) toolCalls = chunk.message.tool_calls;
  }
  }
  log(`[turn ${turn}] got a response in ${((Date.now() - turnStart) / 1000).toFixed(1)}s`);
 
  const message: ChatMessage = { role: "assistant", content, tool_calls: toolCalls };
  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: "" };
 }