A circuit breaker for LangChain agents

LangChain’s AgentExecutor ships with max_iterations and max_execution_time. They are the right primitives for a 2023 agent that ran in seconds and called free models. In 2026 they catch the loop after the bill, not before it. This page is the runtime breaker we ship and how it slots into a LangChain Tool in eight lines.

Where loops actually happen inside a LangChain agent

Why max_iterations and max_execution_time miss it

The two knobs LangChain gives you are correct in shape and wrong in granularity. max_iterations is a count of how many (action, observation) rounds the executor is allowed to take; the default is 15, the agent runs to that ceiling, and only then raises a STOP. By the time round 15 fires, you have made fifteen LLM calls plus fifteen tool calls. If each is $0.10 of model spend and a $1 paid-API call, that’s $16.50 per loop event — before you knew there was a loop. max_execution_time is wall-clock; on agents that legitimately take minutes, it never trips early enough. Neither knob looks at the content of the calls. A run that legitimately needs 12 distinct steps and a run that fires the same broken call 12 times look identical to the executor.

What a circuit breaker actually has to do

Wrapping a LangChain Tool with @runguard/sdk

// langchain.js + @runguard/sdk. The Tool stays a Tool; only its func gets
// wrapped. AgentExecutor sees the same interface, the breaker sees every call.
import { DynamicTool } from 'langchain/tools';
import { guard, LoopDetectedError, BudgetExceededError } from '@runguard/sdk';

const guardedFetch = guard(
  async ({ url }: { url: string }) => {
    const r = await fetch(url);
    return { status: r.status, body: await r.text() };
  },
  {
    signature: ({ url }) => `http_get:${url}`,
    loop: { repeats: 3, maxCycleLen: 8 },
    budget: { maxUsd: 5 },
    cost: (_in, out) => out.status >= 400 ? 0 : 0.001,
    onTrip: async (e) => { console.error('[runguard]', e.reason, e.signature); },
  },
);

const tool = new DynamicTool({
  name: 'http_get',
  description: 'Fetch a URL and return status + body. Trips on third identical call.',
  func: async (input: string) => {
    try {
      return JSON.stringify(await guardedFetch({ url: input }));
    } catch (e) {
      if (e instanceof LoopDetectedError) throw e; // stop the executor
      if (e instanceof BudgetExceededError) throw e;
      return `error: ${(e as Error).message}`;
    }
  },
});

Defaults match every other surface in the SDK: windowSize: 32, minCycleLen: 1, maxCycleLen: 8, repeats: 3. The wrapped function is plain, non-LangChain async — so the same wrap composes with raw fetch, with OpenAI.chat.completions.create, with whatever you reach for next sprint. The fingerprint-and-window approach is documented at how to detect LLM tool-call loops in production.

How the breaker behaves inside an AgentExecutor

Tuning for LangChain’s loop shapes

LangChain’s AgentExecutor defaults to max_iterations: 15. A breaker tuned to repeats: 3, maxCycleLen: 8 can catch a length-1 loop on iteration 3 and a length-2 ping-pong on iteration 6 — both well inside the executor’s ceiling. If your tools genuinely retry idempotent reads (eventually-consistent stores, slow upstreams that recover), pass retryable: true on the call site so the detector skips that signature, or split your tool into a per-attempt one that the detector watches and an outer-retry one that it does not. For high-cost runs — a research agent paying $0.50 per LLM step — consider repeats: 2 on tools whose loop signatures are unique enough that a false-positive trip is cheap. The cost of a missed loop is the bill; the cost of a false-positive trip is one re-run.

Budget and context guards, on the same wrap

The first loop our SDK caught was ours

It wasn’t a LangChain agent — it was our own launch script firing a six-tweet thread against a shared paid API. The first attempt came back with HTTP 402 CreditsDepleted. Six consecutive sessions later, six identical signatures — post_tweet:402:CreditsDepleted — were sitting in a flat JSON file on disk. The seventh session loaded the six-row history, pushed it into the detector at startup, and exited at signature three with RunGuardTripped before a single HTTP request went out. It has held the breaker open every session since. Read the dogfood story on the 30-day log; the same pattern slots into LangChain’s AgentExecutor when the loop is across iterations instead of across sessions.

What this is not

The minimum LangChain integration

One npm i @runguard/sdk, one guard() call per tool whose loop you want to catch, one onTrip that pages the channel you actually read. Eight lines of wrap per tool, no callback handler to register, no executor fork. The breaker trips on the third repeat of any signature, halts the executor, and leaves a structured error and a trip event behind for the post-mortem you would have written on Sunday anyway. RunGuard ships it as @runguard/sdk on npm and runguard on PyPI — same primitive, both runtimes, in-process, zero deps.