{"slug": "show-hn-loopgain-stop-agent-loops-with-control-theory-not-max-iterations", "title": "Show HN: LoopGain – Stop agent loops with control theory, not max_iterations", "summary": "LoopGain, an open-source Python library, replaces fixed max_iterations in AI agent loops with a control-theoretic stop-and-rollback policy based on the Barkhausen criterion, reducing API spend by 92.8% and median runtime by ~15× while preserving output quality in benchmarks across 2,000 trials.", "body_md": "**An open-source cost controller for AI agent loops.**\n\nAI agent loops waste time and money when they don't know when to stop. LoopGain measures the loop in real time and stops it the moment it has actually converged — and rolls back before it degrades — instead of running to a fixed `max_iterations`\n\ncap.\n\nBenchmark — 2,000 paired trials across 10 workload cells([run it yourself]):\n\n92.8% less API spendthan`max_iter=20`\n\n— $27.05 → $1.94 in total benchmark spend~15× faster— median wall-clock per trial 30.9s → 2.1sQuality preserved, not traded for speed— judge win-rate 0.50–0.63 on natural-distribution workloads (W1–W4, CI excluding null on most cells), 0.92–0.95 on engineered-failure workloads (W5); 0.678 weighted preference across 1,800 judge comparisonsZero of six kill criteria fired(all six pre-registered with thresholds before the run)\n\n**Honest limits, up front:** LoopGain detects *convergence, not correctness* — it knows when more iterations won't help, not whether the answer is right, and it's only as good as the verifier behind your error signal. [The full list of what it can't do →](#what-loopgain-does-and-doesnt-guarantee)\n\n**Home:** [loopgain.ai](https://loopgain.ai)\n\nWorks for **any iterative AI workflow with a measurable error signal** — verify-revise loops, refinement passes, tool-use retry chains, RAG with self-correction, code-gen with linter feedback, multi-step reasoning loops. **Pre-built adapters for LangGraph, CrewAI, AutoGen, LangChain, OpenAI Agents SDK, and Claude Agent SDK**; drop-in via the raw API for any custom stack. Pure Python, no runtime dependencies.\n\nProduction agent loops universally use `max_iterations=N`\n\nas their termination policy. It's the embarrassing default of agentic AI: you either waste compute (loop stops too late) or ship bad output (loop stops too early). LoopGain replaces it with a control-theoretic stop-and-rollback policy grounded in the **Barkhausen criterion** — a foundational result from electrical-engineering feedback-oscillator analysis (1921).\n\n```\npip install loopgain\n```\n\nPure Python, no dependencies, supports Python 3.10+.\n\n**Using Claude Code?** The [loopgain-plugin](https://github.com/loopgain-ai/loopgain-plugin)\nscans your whole repo for wrappable loops — literal, recursive, graph-cycle, and\nsemantic — and proposes reviewed diffs one file at a time (never auto-applied):\n\n```\n/plugin marketplace add loopgain-ai/loopgain-plugin\n/plugin install loopgain\n```\n\nThree lines of code wrap any iterative loop with a measurable error signal:\n\n``` python\nfrom loopgain import LoopGain\n\nlg = LoopGain(target_error=0.1)\n\nwhile lg.should_continue():\n    errors = verifier.verify(output)\n    lg.observe(errors, output=output)\n    output = reviser.revise(output, errors)\n\nresult = lg.result\nprint(result.outcome)              # \"converged\" | \"oscillating\" | \"diverged\" | \"stalled\" | \"max_iterations\"\nprint(result.best_output)          # the lowest-error iteration's output\nprint(result.iterations_used)\nprint(result.savings_vs_fixed_cap)\n```\n\n`observe()`\n\naccepts either a numeric error magnitude or any sequence (whose length becomes the magnitude). Pass `output=...`\n\nto enable the best-so-far buffer.\n\nThe one thing you provide is the **error signal**: a single non-negative number, every iteration, that says how wrong the current output is. **Lower is better; zero means done.** LoopGain doesn't know what your loop does — it just watches that number's trajectory and decides whether to keep going, stop, or roll back.\n\nYour loop already has some way of knowing the output isn't good yet (or it wouldn't keep revising). Turn that into a number:\n\n| Loop | Error signal = |\n|---|---|\n| Agentic coding (write code → run tests) | number of failing tests (10 → 3 → 0) |\n| JSON / structured extraction | number of schema violations |\n| RAG with self-correction | number of required facts still missing |\n| Self-refinement with an LLM judge | judge's gap to target (e.g. `10 − quality_score` ) |\n| Lint / format loop | lint error count |\n\nThe only rules: non-negative, and **smaller as the output gets better**. Returning the raw list of problems works directly — `observe()`\n\nuses its length as the magnitude (e.g. hand it the list of failing tests).\n\nIf your quality is fuzzy and has no natural \"zero,\" run with `target_error=None`\n\n: LoopGain then stops when the number **stops improving**, wherever that plateau is, instead of waiting for an exact target.\n\nEvery stop/continue decision is made from this one number, so **LoopGain is only as good as the error signal you give it** — pick one that genuinely tracks output quality.\n\nLoopGain measures empirical loop gain (`Aβ = E(n) / E(n-1)`\n\n) at every iteration and exposes it as a smoothed time series for visualization. The decision engine, however, classifies the **full error trajectory** using four features:\n\n```\nE_ratio   = E_current / E_first      # cumulative reduction\nslope_log = OLS slope of log10(E)    # geometric trend direction\nslope_p   = t-test p-value of slope  # statistical significance\nosc_std   = std of detrended log10(E) # oscillation magnitude\n```\n\nIt routes the trajectory into one of five named states:\n\n| State | Condition | Action |\n|---|---|---|\n`FAST_CONVERGE` |\ncumulative reduction to ≤ 10% of E_first | Continue |\n`CONVERGING` |\nnegative slope with `p < 0.05` , OR cumulative ≤ 50% |\nContinue, watch for upward drift |\n`STALLING` |\nno significant slope, no detectable oscillation | Stop after 2 consecutive readings — return best-so-far |\n`OSCILLATING` |\nhigh residual variance with flat trend | Stop — return best-so-far |\n`DIVERGING` |\npositive slope with `p < 0.05` AND cumulative > 110% |\nAbort — roll back to best-so-far |\n\nPlus a short-circuit: if observed error drops at or below `target_error`\n\n, the loop stops immediately with state `TARGET_MET`\n\n. The default `target_error=0.0`\n\nshort-circuits on exactly zero error — the natural completion signal for verifier-driven loops. Pass `target_error=None`\n\nto disable the short-circuit and rely on stability detection alone.\n\nThe decision is **conservative by design**: requiring both statistical significance and meaningful cumulative motion before terminating prevents false-positive aborts on noisy real-LLM error series. Validated at 98.8% macro-averaged accuracy across 5 regimes on N=1000 deterministic-mock trajectories (see `RESULTS_v2_classifier.md`\n\n). The STALLING ceiling of ~94% is the t-test's irreducible 5% type-I error rate, not a classifier weakness.\n\n**Recommended minimum: 6 iterations** for reliable trend significance. At n≤4 the t-test is severely underpowered (df=2 requires |t|>4.3 for p<0.05) — the classifier conservatively falls back to STALLING when evidence is thin. The thresholds are derived analytically (control theory + statistical convention), not fitted; tune them per domain via the `TrajectoryThresholds`\n\nargument once you have production traces.\n\n**Legacy single-feature classifier:** the original v0.1 single-Aβ-band classifier (thresholds 0.3 / 0.85 / 0.95 / 1.05) is still available via `LoopGain(classifier='legacy_bands')`\n\nfor callers that have empirically tuned the bands to a specific workload.\n\nLoopGain keeps a buffer of all observed outputs paired with their error scores. On termination it returns `argmin(error)`\n\n, not the last iteration:\n\n| Terminal state | Returned output |\n|---|---|\n`TARGET_MET` |\nCurrent output (by definition, the best) |\n`OSCILLATING` |\nLowest-error iteration in the buffer |\n`DIVERGING` |\nLowest-error iteration (which is not the last one) |\n\nThis transforms divergence detection from \"abort with garbage\" into \"abort with the best you've seen so far\" — a free quality floor.\n\nThe library is the whole product locally — telemetry is opt-in and self-hostable. If you want a fleet view of every loop's stability, cost, and rollbacks across a team, there's a hosted dashboard fed by the [telemetry receiver](https://github.com/loopgain-ai/telemetry-receiver):\n\n** Open the live demo →** — no signup, real benchmark data.\n\nThe receiver and dashboard are both open-source — self-host to keep telemetry entirely under your control.\n\n| Repo | What it is |\n|---|---|\nloopgain |\n\n**telemetry-receiver****dashboard****loopgain-bench****loopgain-plugin** LoopGain saves money by stopping a loop once it stops improving — fewer iterations, fewer tokens. In our [public benchmark](https://github.com/loopgain-ai/loopgain-bench), that was a **92.8% cut in total API spend** vs `max_iterations=20`\n\n, with output quality preserved. Two honest limits:\n\n**Savings depend on your workload.** Loops that usually succeed fast save the most (~96%); adversarial, failure-prone loops save less (~78–84%). The headline is a blend — run the benchmark on your own loops before quoting a number.**LoopGain detects convergence, not correctness.** It stops when your error signal stops improving — which means more iterations won't help,*not*that the loop succeeded. On the benchmark this preserved quality (it rarely stopped early on a worse output; false-stop rate ≤4.5%), but a loop can stall with the error still above zero — a plateau at, say, 2 failing tests. So check`result.best_error`\n\n(or your own pass/fail) before you trust the output: if it plateaued short of your target, that's a quality gap LoopGain can't see, and a false stop that forces a rerun is the one way it eats into the savings. LoopGain decides*when to stop*; you decide*whether the answer is good enough*.**LoopGain is only as right as your verifier.** It acts on the error signal you give it. If your verifier reports zero errors, LoopGain trusts that and stops — so a verifier with blind spots can report success on an answer that is still wrong, and LoopGain will confidently stop there. This is not the plateau case above: the error reads zero and the loop looks like a clean success, so neither LoopGain nor its convergence signal can flag it. The quality of the stop is bounded by the quality of the check behind your error signal. We measured this on the benchmark's code-gen workload:**4.5% of converged runs (16/355) passed every check the loop ran but failed the full held-out test suite**— and that's a floor, not a ceiling, because the in-loop verifier there was strong; a weaker verifier exposes more. (Distinct from the ≤4.5% false-stop rate above — the numbers coincide, the failure modes don't.) Pair LoopGain with the strongest verifier you can afford at the stop — executable tests over a sampled subset, a schema or type check over a vibe, a held-out check the loop didn't optimize against.is a field guide to exactly this.[How to design a strong verifier](https://loopgain.ai/blog/posts/how-to-design-a-strong-verifier/)\n\n`LoopGain(target_error=0.0, max_iterations=50, thresholds=None, trajectory_thresholds=None, classifier='trajectory', smoothing_window=3, assumed_fixed_cap=10)`\n\nConstruct the monitor.\n\n`target_error`\n\n— Stop when an observed error drops at or below this. Default`0.0`\n\nshort-circuits on exactly zero error (the natural completion signal for verifier-driven loops). Pass`None`\n\nto disable the short-circuit entirely.`max_iterations`\n\n— Hard safety backstop. Default`50`\n\nso the loop can never run unbounded; a stability verdict normally terminates it well before this. Pass`None`\n\nto opt into a fully unbounded loop (only safe if your loop is guaranteed to reach`target_error`\n\nor a stop-state), or a smaller integer to cap tighter.`thresholds`\n\n— Custom`ThresholdBands`\n\nfor the legacy single-Aβ-band classifier. Ignored when`classifier='trajectory'`\n\n.`trajectory_thresholds`\n\n— Custom`TrajectoryThresholds`\n\nfor the multi-feature classifier (the default). Override only with workload-specific evidence.`classifier`\n\n—`'trajectory'`\n\n(default, v0.2 multi-feature classifier) or`'legacy_bands'`\n\n(v0.1 single-Aβ-band classifier).`smoothing_window`\n\n— EMA window for the smoothed Aβ series (always maintained for visualization, regardless of classifier choice). Default 3.`assumed_fixed_cap`\n\n— Used to compute`savings_vs_fixed_cap`\n\n. Default 10.\n\nRecord this iteration's errors and optional output. Returns the current state name. `errors`\n\naccepts a number (used directly) or any sequence (length used as magnitude).\n\nReturns `False`\n\nonce a terminal state fires.\n\nCurrent state name. One of `INIT`\n\n, `FAST_CONVERGE`\n\n, `CONVERGING`\n\n, `STALLING`\n\n, `OSCILLATING`\n\n, `DIVERGING`\n\n, `TARGET_MET`\n\n, `MAX_ITERATIONS`\n\n. The corresponding terminal `result.outcome`\n\nvalues are `converged`\n\n, `oscillating`\n\n, `diverged`\n\n, `stalled`\n\n(v0.2 trajectory mode only — STALLING terminating after 2 consecutive readings), `max_iterations`\n\n, or `in_progress`\n\n.\n\nTerminal result with `outcome`\n\n, `iterations_used`\n\n, `best_index`\n\n, `best_output`\n\n, `best_error`\n\n, `convergence_profile`\n\n, `error_history`\n\n, `savings_vs_fixed_cap`\n\n. Safe to call mid-loop.\n\n`lg.send_telemetry(endpoint=None, token=None, workload_id=None, timeout=2.0, allow_insecure=False, framework=None, loop_type=None, team=None, include_per_iteration=True, retries=2, retry_backoff=0.25, actual_dollars_spent=None, actual_dollars_saved=None) -> bool`\n\n**Opt-in.** Send a single anonymized telemetry POST after the loop terminates. Best-effort — never raises, returns `True`\n\non 2xx, `False`\n\notherwise. Adapters auto-stamp `framework`\n\n; `loop_type`\n\nand `team`\n\nare free-form labels that surface as filters in the dashboard. Pass `include_per_iteration=False`\n\nto send aggregate summary only.\n\n`endpoint`\n\nand `token`\n\nare optional (v0.6.3+): with `LOOPGAIN_TELEMETRY_ENDPOINT`\n\nand `LOOPGAIN_TELEMETRY_TOKEN`\n\nexported, a bare `lg.send_telemetry()`\n\nis fully configured — the endpoint may be the receiver base URL (`https://telemetry.loopgain.ai`\n\n) or the full `/v1/aggregate`\n\npath. Nothing configured → returns `False`\n\n, sends nothing.\n\n`actual_dollars_spent`\n\nand `actual_dollars_saved`\n\nare optional real-cost fields (v0.6.1+). Populate them only when you have a genuinely *measured* dollar figure — summed real API usage x list price, or an actually-executed paired-baseline comparison run. Never a formula-derived estimate. When populated, the dashboard displays your real number instead of its iter-count x $/iter extrapolation; passing an estimate through this field would present it as ground truth to every consumer of your tenant's data, not just you.\n\n``` python\nfrom loopgain import LoopGain\n\nlg = LoopGain(target_error=0.1)\n# ... run the loop ...\nlg.send_telemetry(workload_id=\"my-rag-pipeline\")  # endpoint/token from env (v0.6.3+)\n```\n\nVerify the pipeline before wiring a real loop — `loopgain doctor`\n\nruns a tiny in-process loop (no model calls, $0) and sends one test event:\n\n```\nexport LOOPGAIN_TELEMETRY_ENDPOINT=\"https://telemetry.loopgain.ai\"\nexport LOOPGAIN_TELEMETRY_TOKEN=\"lgk_...\"\nloopgain doctor\n# -> event accepted by the receiver -> appears in your dashboard as 'loopgain-doctor'\n```\n\nRecommended setup: store the token outside source. Two clean options:\n\n```\n# Option A: environment variable (simplest)\nexport LOOPGAIN_TELEMETRY_ENDPOINT=\"https://telemetry.loopgain.ai/v1/aggregate\"\nexport LOOPGAIN_TELEMETRY_TOKEN=\"lgk_...\"   # add to ~/.zshrc or ~/.bashrc\n\n# Option B: macOS Keychain (more secure)\npip install keyring\npython3 -c \"import keyring; keyring.set_password('loopgain', 'telemetry', input('Token: '))\"\n# Then in code: keyring.get_password('loopgain', 'telemetry')\n```\n\nWhat is sent: state transitions, Aβ summary (min/max/median), rollback flag, iterations used, savings, library version, optional opaque `workload_id`\n\n, threshold config, hour-bucketed timestamp — and, unless you pass `include_per_iteration=False`\n\n, a length-capped per-iteration trajectory (smoothed Aβ values and numeric error magnitudes; this is what drives the dashboard's convergence-profile scrubbing).\n\n**What is NEVER sent: prompts, completions, error contents, the output buffer, or any customer identity beyond the bearer token.** Numeric error *magnitudes* are sent (they're the loop-gain signal); error *contents* never are. Privacy contract is enforced by the payload-shape unit tests in `tests/test_telemetry.py`\n\n.\n\nThe hosted endpoint at `telemetry.loopgain.ai`\n\nis one acceptable destination. The [receiver](https://github.com/loopgain-ai/telemetry-receiver) and [dashboard](https://github.com/loopgain-ai/dashboard) are both open-source — self-host to keep telemetry fully under your control.\n\nThis is not the same as anonymous usage telemetry.`send_telemetry`\n\nsendsyourloop data toyourdashboard, and only when you call it. There's a separate, opt-infunneltelemetry described below. The two never share data or code.\n\nLoopGain can report **anonymous usage counts** so a solo maintainer can tell whether the library is actually being used — install → first `observe()`\n\n→ recurring use. **It is opt-in and default-decline: nothing is sent unless you explicitly turn it on.**\n\n```\nloopgain telemetry --show       # status + exactly what would be sent\nloopgain telemetry --enable     # opt in   (or: export LOOPGAIN_TELEMETRY=1)\nloopgain telemetry --disable    # opt out  (or: export LOOPGAIN_TELEMETRY=0)\n```\n\n`DO_NOT_TRACK=1`\n\nis honored as a hard opt-out, and CI environments are auto-detected and declined silently. When enabled, payloads carry only a locally-generated random id (not derived from your machine), hour-bucketed timestamps, library/Python/OS versions, the adapter in use, and a coarse outcome count. **Prompts, outputs, error contents, keys, paths, and IPs are never collected.** Delivery is batched, async, https-only, and fail-silent — it can never break your loop. Full details and the privacy contract: ** TELEMETRY.md**.\n\nIf LoopGain is useful to you, opting in is the cheapest way to support the project — these counts are the only signal a solo-maintained library has that it's working for anyone.\n\n```\nloopgain --version              # or: loopgain version\nloopgain telemetry --show       # inspect / control anonymous funnel telemetry\npython -m loopgain telemetry --show   # equivalent, without the console script\n```\n\nThin wrappers under `loopgain.integrations`\n\ndrive each major agent framework's iteration with a `LoopGain`\n\nmonitor and auto-stamp `framework=\"<name>\"`\n\non telemetry. The frameworks themselves are **optional dependencies** — install the extra you need:\n\n```\npip install 'loopgain[langgraph]'          # LangGraph\npip install 'loopgain[crewai]'             # CrewAI\npip install 'loopgain[autogen]'            # AutoGen v0.4+\npip install 'loopgain[langchain]'          # LangChain (create_agent / AgentExecutor)\npip install 'loopgain[openai-agents]'      # OpenAI Agents SDK\npip install 'loopgain[claude-agent-sdk]'   # Anthropic Claude Agent SDK\npip install 'loopgain[all]'                # all six\n```\n\nAll adapters take a `LoopGain`\n\ninstance plus an `error_fn`\n\nyou provide — the framework doesn't know what your error signal is, so the adapter doesn't either. `error_fn`\n\nreturns a non-negative number (or `None`\n\nto skip an iteration).\n\nDrives `graph.stream(input, stream_mode=\"updates\")`\n\n. Each update is one iteration.\n\n``` python\nfrom loopgain import LoopGain\nfrom loopgain.integrations import LangGraphAdapter\n\ngraph = build_my_verify_revise_graph().compile()\nlg = LoopGain(target_error=0.1, max_iterations=20)\n\nadapter = LangGraphAdapter(\n    lg=lg,\n    error_fn=lambda update: len(update.get(\"verifier\", {}).get(\"errors\", [])),\n)\nfinal_state = adapter.run(graph, {\"draft\": initial})\n\nlg.send_telemetry(\n    endpoint=os.environ[\"LOOPGAIN_TELEMETRY_ENDPOINT\"],\n    token=os.environ[\"LOOPGAIN_TELEMETRY_TOKEN\"],\n    workload_id=\"rag-rewrite\",\n    framework=adapter.framework_name,        # \"langgraph\", auto-stamped\n)\n```\n\n`adapter.stream(...)`\n\nyields each item if you want the full trace; `adapter.arun(...)`\n\n/ `adapter.astream(...)`\n\nare the async counterparts and accept an async `error_fn`\n\n.\n\nInstalls `step_callback`\n\nand/or `task_callback`\n\non a Crew. Pick whichever granularity matches your loop — `step_error_fn`\n\nfor refinement *within* a Task, `task_error_fn`\n\nfor refinement *across* Tasks.\n\n``` python\nfrom crewai import Crew\nfrom loopgain import LoopGain\nfrom loopgain.integrations import CrewAIAdapter\n\nlg = LoopGain(target_error=0.1, max_iterations=20)\nadapter = CrewAIAdapter(\n    lg=lg,\n    task_error_fn=lambda task_output: count_failed_checks(task_output.raw),\n)\ncrew = Crew(agents=[...], tasks=[...])\nadapter.install(crew)\nresult = crew.kickoff()\nadapter.uninstall()         # or use `with CrewAIAdapter(...) as a:` context\n\nlg.send_telemetry(\n    endpoint=...,\n    token=...,\n    framework=adapter.framework_name,        # \"crewai\"\n)\n```\n\nThe adapter chains with any callback you already had installed — your existing instrumentation isn't overwritten.\n\nWraps `team.run_stream(task=...)`\n\n. In a verify-revise rotation, filter to the verifier's messages with `observe_sources={\"verifier\"}`\n\nso only it drives `observe()`\n\n.\n\n``` python\nfrom autogen_agentchat.teams import RoundRobinGroupChat\nfrom loopgain import LoopGain\nfrom loopgain.integrations import AutoGenAdapter\n\nteam = RoundRobinGroupChat(participants=[generator, verifier])\nlg = LoopGain(target_error=0.1, max_iterations=20)\nadapter = AutoGenAdapter(\n    lg=lg,\n    error_fn=lambda msg: parse_verifier_score(msg.content),\n    observe_sources={\"verifier\"},\n)\nresult = await adapter.run(team, task=\"...\")\n\nlg.send_telemetry(\n    endpoint=...,\n    token=...,\n    framework=adapter.framework_name,        # \"autogen\"\n)\n```\n\nPass a `cancellation_token`\n\nto `adapter.run(...)`\n\nand the adapter will cancel it when LoopGain reaches a terminal state (target met, oscillation, divergence). The legacy v0.2 `ConversableAgent.initiate_chat`\n\nAPI is **not** supported — use the v0.4 event-driven runtime.\n\nDuck-types against any LangChain agent that exposes `.stream(input, **kwargs)`\n\n/ `.astream(input, **kwargs)`\n\n— both the current `langchain.agents.create_agent()`\n\n(v1+) and the legacy `AgentExecutor`\n\n. The adapter forwards `**stream_kwargs`\n\nverbatim, so the chunk shape your `error_fn`\n\nsees is the one your agent emits.\n\n``` python\nfrom langchain.agents import create_agent\nfrom loopgain import LoopGain\nfrom loopgain.integrations import LangChainAdapter\n\nagent = create_agent(model=\"gpt-5-nano\", tools=[get_weather])\nlg = LoopGain(target_error=0.0, max_iterations=20)\n\ndef error_fn(chunk):\n    if chunk.get(\"type\") != \"updates\":\n        return None\n    # Count unresolved tool calls; drops to 0 once the agent stops calling tools.\n    return sum(\n        1 for _, update in chunk[\"data\"].items()\n        if getattr(update.get(\"messages\", [None])[-1], \"tool_calls\", None)\n    )\n\nadapter = LangChainAdapter(lg=lg, error_fn=error_fn)\nfinal = adapter.run(\n    agent,\n    {\"messages\": [{\"role\": \"user\", \"content\": \"What's the weather?\"}]},\n    stream_mode=\"updates\",\n    version=\"v2\",\n)\n\nlg.send_telemetry(\n    endpoint=...,\n    token=...,\n    framework=adapter.framework_name,        # \"langchain\"\n)\n```\n\nFor legacy `AgentExecutor`\n\n: just drop the `stream_mode`\n\n/ `version`\n\nkwargs; each yielded chunk is an `AddableDict`\n\nper step (parse `intermediate_steps`\n\nor the terminal `output`\n\nkey in your `error_fn`\n\n).\n\nWraps `Runner.run_streamed(agent, input).stream_events()`\n\n. The SDK is async-first; the adapter mirrors that. A `run_sync`\n\nhelper wraps the async path with `asyncio.run`\n\nfor synchronous callers.\n\n``` python\nfrom agents import Agent, function_tool\nfrom loopgain import LoopGain\nfrom loopgain.integrations import OpenAIAgentsAdapter\n\nagent = Agent(name=\"Reviser\", instructions=\"...\", tools=[...])\n\nlg = LoopGain(target_error=0.0, max_iterations=20)\n\ndef error_fn(event):\n    # Default observes only run_item_stream_event; pull the verifier's\n    # reported failure count off tool outputs.\n    if event.item.type == \"tool_call_output_item\":\n        return float(event.item.output.get(\"failures\", 0))\n    return None\n\nadapter = OpenAIAgentsAdapter(lg=lg, error_fn=error_fn)\nresult = await adapter.run(agent, input=\"Fix the bug.\")\nprint(result.final_output)\n\nlg.send_telemetry(\n    endpoint=...,\n    token=...,\n    framework=adapter.framework_name,        # \"openai-agents\"\n)\n```\n\nBy default the adapter only forwards `run_item_stream_event`\n\nto `error_fn`\n\n— pass `observe_event_types=None`\n\nto see every event (including raw token deltas and agent-handoff notifications). When LoopGain reaches a terminal state, the adapter best-effort calls `.cancel()`\n\non the underlying `RunResultStreaming`\n\n.\n\nWraps Anthropic's `claude_agent_sdk.query(prompt=..., options=...)`\n\nasync iterator. By default observes only `AssistantMessage`\n\n(skips `UserMessage`\n\n/ `SystemMessage`\n\n/ `ResultMessage`\n\n); override with `observe_message_types=None`\n\nor a custom tuple.\n\n``` python\nfrom claude_agent_sdk import ClaudeAgentOptions, TextBlock\nfrom loopgain import LoopGain\nfrom loopgain.integrations import ClaudeAgentSDKAdapter\n\ndef error_fn(message):\n    # Count `FAIL:` markers a self-verifying persona emits.\n    for block in getattr(message, \"content\", []):\n        if isinstance(block, TextBlock):\n            return float(block.text.count(\"FAIL:\"))\n    return None\n\nlg = LoopGain(target_error=0.0, max_iterations=20)\nadapter = ClaudeAgentSDKAdapter(lg=lg, error_fn=error_fn)\n\noptions = ClaudeAgentOptions(system_prompt=\"Self-verify each draft.\")\nresult = await adapter.run(\n    prompt=\"Write a haiku about feedback loops.\",\n    options=options,\n)\n\nlg.send_telemetry(\n    endpoint=...,\n    token=...,\n    framework=adapter.framework_name,        # \"claude-agent-sdk\"\n)\n```\n\nFor the bidirectional `ClaudeSDKClient`\n\nuse case, pass `message_iterator=client.receive_messages()`\n\ninstead of `prompt=...`\n\n.\n\nFor frameworks without an adapter, the raw `LoopGain.observe()`\n\nAPI works against any iterable. The adapters are 100-200 lines each — copy one of `loopgain/integrations/{langgraph,crewai,autogen,langchain,openai_agents,claude_agent_sdk}.py`\n\nas a starting point.\n\n**Initial public release.** Core library shipped (current version: see the PyPI badge at the top). Framework adapters (LangGraph, CrewAI, AutoGen, LangChain, OpenAI Agents SDK, Claude Agent SDK) are installable as optional extras. The cloud-aggregator [telemetry receiver](https://github.com/loopgain-ai/telemetry-receiver) and [dashboard](https://github.com/loopgain-ai/dashboard) are live as separate open-source repos. The math and the API surface are stable.\n\nThis is alpha software. The API may break before 1.0 if production usage surfaces design issues; pin the version.\n\nLoopGain applies the **Barkhausen stability criterion** (Heinrich Barkhausen, 1921 — the foundational result on when feedback amplifiers oscillate) to AI agent feedback loops. The criterion was originally a way to predict whether an electronic oscillator would sustain oscillation; it turns out to map cleanly onto any feedback loop you can attach an error signal to.\n\nThe cleanest summary: an iterative AI loop with a measurable error signal is a feedback system. The ratio `E(n) / E(n-1)`\n\nis its empirical loop gain. The Barkhausen result tells you that loop gain less than 1 converges, equal to 1 oscillates, greater than 1 diverges. LoopGain operationalizes this: classifies the loop's current band, and decides what to do — stop, continue, or roll back to the best output seen so far.\n\nLoop types this applies to in practice:\n\n**Verify-revise loops**(GVR pattern) — generator produces, verifier finds issues, reviser fixes. Error = issue count or severity-weighted score.**Refinement loops**— initial output, iterate to improve. Error = distance from target spec / rubric score.** Tool-use retry chains**— agent calls tool, gets back error/success, retries. Error = consecutive failure count or aggregate score.** RAG with self-correction**— retrieve, generate, critique, re-retrieve. Error = critique severity or hallucination score.** Code generation with linter/test feedback**— generate, run tests/linter, fix, repeat. Error = failing test count or linter violation count.** Multi-step reasoning loops**— ReAct-style think/act/observe iterations. Error = whatever the agent's quality assessor returns.** Custom feedback loops**— anything where you can produce a number that should drop toward zero as the loop succeeds.", "url": "https://wpnews.pro/news/show-hn-loopgain-stop-agent-loops-with-control-theory-not-max-iterations", "canonical_source": "https://github.com/loopgain-ai/loopgain", "published_at": "2026-07-15 12:07:48+00:00", "updated_at": "2026-07-15 12:18:14.191710+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "machine-learning", "artificial-intelligence"], "entities": ["LoopGain", "LangGraph", "CrewAI", "AutoGen", "LangChain", "OpenAI Agents SDK", "Claude Agent SDK"], "alternates": {"html": "https://wpnews.pro/news/show-hn-loopgain-stop-agent-loops-with-control-theory-not-max-iterations", "markdown": "https://wpnews.pro/news/show-hn-loopgain-stop-agent-loops-with-control-theory-not-max-iterations.md", "text": "https://wpnews.pro/news/show-hn-loopgain-stop-agent-loops-with-control-theory-not-max-iterations.txt", "jsonld": "https://wpnews.pro/news/show-hn-loopgain-stop-agent-loops-with-control-theory-not-max-iterations.jsonld"}}