zz_

app/agent/loop.ts

zzmq.dev@gmail.com/zz / app / agent / loop.ts @ 5e5720fb 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 generic tool-calling loop over any ChatBackend (agent/
// chatBackend.ts) -- local Ollama or a remote API (Gemini). The loop
// itself knows nothing about either provider's wire format; it just
// exchanges ChatMessages and executes whatever tool_calls come back.
import type { ToolDef } from "./tools.js";
import type { ChatBackend, ChatMessage } from "./chatBackend.js";

// 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[];
  backend: ChatBackend;
  maxTurns?: number;
  /** 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;
  /**
   * Overrides how a tool call actually runs -- used for `zz work`
   * against a remote repo: the tool's real name/schema (for the
   * model) stays the same, but execution is proxied to the deployed
   * Worker (cli/lib/remoteTools.ts's callRemoteTool) instead of
   * calling `tool.execute` in this process. Defaults to calling
   * `tool.execute(args)` directly, today's local behavior.
   */
  executeTool?: (tool: ToolDef, args: Record<string, unknown>) => Promise<unknown>;
  /**
   * 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 | Promise<boolean>;
}

export interface RunAgentResult {
  turns: number;
  finished: boolean;
  finalMessage: string;
}

const MAX_GIVE_UP_NUDGES = 2;
const MAX_BACKEND_RETRIES = 3;
const BACKEND_RETRY_DELAYS_MS = [3000, 8000, 15000];

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

// A remote backend can fail transiently (rate limit, "high demand"
// 503s -- observed directly against Gemini's free tier) in ways a
// local one rarely does once it's loaded. An uncaught failure used to
// crash the entire zz work process, not just this one attempt --
// bounded retry with backoff first; null means give up on this
// attempt (not the process) once that's exhausted.
async function chatWithRetry(backend: ChatBackend, messages: ChatMessage[], tools: ToolDef[], onPartial: ((text: string) => void) | undefined, turn: number, log: (line: string) => void): Promise<ChatMessage | null> {
  for (let attempt = 1; attempt <= MAX_BACKEND_RETRIES; attempt++) {
    try {
      return await backend.chat(messages, tools, onPartial);
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      if (attempt === MAX_BACKEND_RETRIES) {
        log(`[turn ${turn}] ${backend.name} failed after ${MAX_BACKEND_RETRIES} attempts, giving up on this attempt: ${msg}`);
        return null;
      }
      const delay = BACKEND_RETRY_DELAYS_MS[attempt - 1] ?? BACKEND_RETRY_DELAYS_MS[BACKEND_RETRY_DELAYS_MS.length - 1];
      log(`[turn ${turn}] ${backend.name} failed (attempt ${attempt}/${MAX_BACKEND_RETRIES}), retrying in ${delay / 1000}s: ${msg}`);
      await sleep(delay);
    }
  }
  return null;
}

export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
  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 ${opts.backend.name}...`);
    const turnStart = Date.now();
    const message = await chatWithRetry(opts.backend, messages, opts.tools, opts.onPartial, turn, log);
    if (!message) return { turns: turn, finished: false, finalMessage: "" };
    log(`[turn ${turn}] got a response in ${((Date.now() - turnStart) / 1000).toFixed(1)}s`);
    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 (!(await 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 {
          const args = coerceJsonStringArgs(call.function.arguments);
          result = opts.executeTool ? await opts.executeTool(tool, args) : await tool.execute(args);
        } 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), name: call.function.name });
    }
  }

  log(`stopped after ${maxTurns} turns without the agent finishing on its own.`);
  return { turns: maxTurns, finished: false, finalMessage: "" };
}