jonaki

Error handling

Every code Jonaki throws, what causes it, and how to fix it.

Every error Jonaki throws is a JonakiError with a stable code, structured details, and an actionable hint.

import { JonakiError, run } from "@rohhitrz/jonaki";

try {
  await run(agent, "hi");
} catch (error) {
  if (JonakiError.is(error, "BUDGET_EXCEEDED")) {
    console.log(error.code);    // "BUDGET_EXCEEDED"
    console.log(error.details); // { maxCostUSD: 0.1, spentUSD: 0.16, turn: 2 }
    console.log(error.hint);    // "the run reached its cost ceiling…"
  }
}

Messages are multi-line and readable:

JONAKI_TOOL_INPUT_INVALID
  tool:     get_weather
  received: { "cty": "Guwahati" }
  expected: { city: string }
  hint:     the model sent a malformed argument; jonaki will repair and retry

Every code

CodeCauseFix
NO_PROVIDERNo API key found, and neither an explicit provider nor a recognisable model prefix was given.Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY — or pass provider, or a model like claude-sonnet-4-5.
INVALID_CONFIGAn Agent was constructed without a name.Give it a name; it identifies the agent in traces and handoff tool names.
TOOL_INPUT_INVALIDThe model produced arguments that failed Zod validation twice in a row.Sharpen the tool's description and field .describe() text; simplify the schema. Single failures are repaired automatically.
TOOL_FAILEDA tool threw, or the run was cancelled mid-tool. Also used when a paused run is resumed without a decision.Read details.error. A throwing tool is reported to the model, so this only surfaces when the run is stopped for another reason.
TOOL_TIMEOUTA tool exceeded timeoutMs (default 30s).Raise timeoutMs, or make the tool honour ctx.signal and return early.
MAX_TURNS_EXCEEDEDThe agent kept calling tools without finishing, hitting maxTurns (default 10).Raise maxTurns, narrow the task, or check for a tool that always fails and sends the model in circles.
BUDGET_EXCEEDEDAccumulated cost reached maxCostUSD before a model call.Raise maxCostUSD, reduce turns, or use a cheaper model. Checked before the call, so nothing is spent past the ceiling.
GUARDRAIL_BLOCKEDA guardrail returned { action: "block" }.details names the guardrail, stage, and reason. Change the input or relax the guardrail.
OUTPUT_INVALIDThe final message did not match the output schema, even after one repair.details.received holds the raw text. Simplify the schema, add .describe() hints, or make optional fields nullable.
HANDOFF_DEPTH_EXCEEDEDAgents chained deeper than maxHandoffDepth (default 3). Also raised if a run names an agent unreachable from the root.Raise maxHandoffDepth, or check for a handoff cycle.
PROVIDER_ERROREvery model in the fallback ladder failed.details.failures lists what each said — usually credentials, a bad model id, or rate limits.
RUN_TIMEOUTThe whole run exceeded timeoutMs.Raise timeoutMs or reduce the work per run.
RUN_ABORTEDThe caller aborted via signal.Expected when you cancel deliberately.
STORE_ERRORMisconfiguration, a missing better-sqlite3, an unreadable file, or an unknown runId.details names the store and path. session without store is the most common cause.
REPLAY_DIVERGEDA recording no longer matches the code: a tool was deleted, or the run needs more turns than were recorded.Restore the tool, or re-record with JONAKI_RECORD.

Narrowing

JonakiError.is is a type guard and takes an optional code:

import { JonakiError } from "@rohhitrz/jonaki";

if (JonakiError.is(error)) {
  // error.code, error.details, error.hint are all available
}

if (JonakiError.is(error, "RUN_TIMEOUT")) {
  // narrowed to that specific failure
}

Plain instanceof works too:

import { JonakiError } from "@rohhitrz/jonaki";

if (error instanceof JonakiError) {
  console.log(error.code);
}

What does not throw

Two things people expect to throw but do not:

  • A tool that fails. The error is reported to the model as a tool result so it can recover. Look for status: "error" steps in the trace.
  • A run that pauses for approval. It returns with status: "awaiting_approval" and no output.
import { run } from "@rohhitrz/jonaki";

const result = await run(agent, "refund $850", { store });

if (result.status === "awaiting_approval") {
  // not an error — a decision is needed
}

Failures are still persisted

When a run fails with a store attached, the failed state is saved before the error is thrown — so the trace survives:

import { run } from "@rohhitrz/jonaki";

try {
  await run(agent, "hi", { store, maxTurns: 2 });
} catch {
  const failed = await store.loadRun(runId);
  console.log(failed?.error);  // { code: "MAX_TURNS_EXCEEDED", message: "…" }
  console.log(failed?.steps);  // everything that happened first
}

Retrying

Codes tell you what is worth retrying:

import { JonakiError, run } from "@rohhitrz/jonaki";

const RETRYABLE = new Set(["PROVIDER_ERROR", "RUN_TIMEOUT"]);

async function runWithRetry(input: string, attempts = 3) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await run(agent, input);
    } catch (error) {
      const retryable =
        JonakiError.is(error) && RETRYABLE.has(error.code) && attempt < attempts;
      if (!retryable) throw error;
      await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 250));
    }
  }
}

Do not blindly retry BUDGET_EXCEEDED, GUARDRAIL_BLOCKED, or MAX_TURNS_EXCEEDED — those mean the run did what you asked it to.

Listing codes at runtime

import { JONAKI_ERROR_CODES } from "@rohhitrz/jonaki";

console.log(JONAKI_ERROR_CODES.length); // 15

On this page