Eleven projects into this series, every one of them took something from open source. Project 12 — the last one — puts something back: the runtime guards that eleven projects of failing in public turned out to need, packaged so somebody else can pip install them, plus a documented, reproduced gap in a real agent framework with a patch verified against the installed library.
Two parts. A library, and a contribution.
Part 1 — agentfuse
"Fuse" as in the electrical kind: it sits in the path, it is dumber than the thing it protects, and it blows before the expensive part burns.
from agentfuse import FuseBox, LoopFuse, PermissionFuse, ToolSpec, BudgetFuse, Price, ToolCall
box = FuseBox(
permission=PermissionFuse(granted={"read"},
specs=[ToolSpec.of("lookup_order", "read"),
ToolSpec.of("issue_refund", "write", "spend_money")]),
loop=LoopFuse(repeat_threshold=3),
budget=BudgetFuse(max_usd=0.05, price=Price(0.05, 0.15)),
)
verdict = box.check_tool_call(ToolCall("issue_refund", {"amount": 40}))
assert verdict.blocked # "needs scope(s) ['spend_money', 'write'] not granted to this run"
Five fuses. Every one exists because something went wrong in an earlier project, not because it seemed like a good idea:
| fuse | what it stops | where it came from |
|---|---|---|
PermissionFuse |
a tool the run was never allowed to touch, or one nobody registered |
P4 — a restricted grant refused ledger_write while the model insisted |
LoopFuse |
the same call again, and the A,B,A,B cycle a counter cannot see | P11 — repeated-signature alerting, moved onto the hot path |
BudgetFuse |
the next model call, when it would breach the $ or token ceiling |
P7 — pre-flight estimate to gate, real usage to report |
JudgeGate |
an LLM marking its own homework | P10 — an 8B judge scored its own draft 1.00 on text that did not satisfy the criterion |
CanaryFuse |
a config release that costs more than the one it replaces | P11 — a candidate at 2.20x baseline cost, rolled back automatically |
Three rules hold throughout. No dependencies — a safety layer that drags in a dependency tree is a safety layer nobody installs, and one more supply chain sitting in the path of your tool calls. Every verdict is a pure function of recorded facts — a guard you cannot replay is a guard you cannot debug at 3am. Ambiguity resolves to stop — an unknown tool, a missing price, unparseable arguments all mean "no", never "probably fine".
That last one is the whole of deny-by-default, and it is what a plain {name: fn} dispatch table cannot give you: a dict lookup either finds a function and runs it, or raises KeyError somewhere deep in the executor. Neither of those is a policy decision. Prompt injection does not have to invent a clever argument if it can simply name a tool you forgot to list.
The failure mode counting cannot see
P11 counted repeated tool signatures across exported spans. Honest, but late — by the time the rule fired, every one of those calls had already been billed. Moving it onto the hot path was the easy half. The interesting half is that a counter is blind to A,B,A,B: no single signature repeats often enough to trip a threshold, so the rule stays silent while the agent ping-pongs between two tools until the step limit kills it.
def _cycle_verdict(self, signature):
seq = self.state.history + [signature]
# Longest period first: A,B,A,B is a period-2 cycle, and reporting it as period-1
# would be wrong (no single signature repeats 2x in a row there).
for period in range(min(self.max_cycle_len, len(seq) // 2), 1, -1):
tail = seq[-2 * period:]
if tail[:period] == tail[period:]:
return Verdict.stop(self.name, f"agent is cycling through {period} tool calls "
f"with no new state")
Measured on the reproducer: the cycle detector fires at tool call 4, the count-only rule this series shipped would fire at 5, and stock LangGraph gets there at 20.
Which is really an argument about what a step cap is for. A step cap answers "has this gone on too long". It does not answer "is this making progress". A ten-step run that never repeats itself is healthy; a four-step run that repeats twice is already dead and should stop at step 3. Frameworks ship the step cap — LangGraph's recursion_limit, CrewAI's max_iter, smolagents' max_steps — and none of them ship the progress check.
The check that was scripted to pass, and did not
First live run against meta/llama-3.1-8b-instruct on NVIDIA NIM:
unguarded: 6 model calls, 6 tool calls, 1924 real tokens
guarded : 6 model calls, 2 tool calls, 2230 real tokens <-- worse
[FAIL] LIVE guarding the loop cut real tokens on the wire — 1924 -> 2230 (-15.9% fewer)
The loop fuse stopped the tool calls exactly as designed, then handed the refusal back to the model — which kept getting called with a larger context every turn. Blocking the tool while continuing to pay for the model is not a saving.
The fix is terminal_fuses in the adapter: a loop refusal ends the run, because the model is the part that is stuck and asking it again is how one wasted call becomes twenty. A permission refusal deliberately stays non-terminal — the model needs one more turn to write an honest answer. After the fix, same prompt, same model:
unguarded: 6 model calls, 6 tool calls, 1924 real tokens, stop=max-turns
guarded : 3 model calls, 2 tool calls, 805 real tokens, stop=blocked
58.2% fewer real tokens on the wire. Nothing about the unguarded run looked broken — every HTTP call was a 200.
The model asked to move money and did not get to
Given "look up order 42 and refund me the full amount", the live 8B model called lookup_order (granted: read) and then issue_refund (needs write + spend_money, not granted). The refund executor ran zero times. The model was told it had been refused, and its own words were:
"The refund was refused due to a lack of permission. The user does not have the necessary scope to issue a refund."
That is Project 6's lesson holding. An agent that is refused and not told will report a success it never had. Told, it says what actually happened.
The spend ceiling works the other way round — it is a pre-flight, and the ledger is not. The run made exactly one live call, booked its real 215 prompt + 19 completion tokens from the API, and the second call was refused before any HTTP happened: projected spend $0.053750 would breach the $0.041947 ceiling. Estimates gate; real usage reports. The two numbers never mix.
One more thing the live run found, which no unit test would have:
openai.InternalServerError: Error code: 500 - Failed to generate completions:
Failed to apply prompt template: invalid operation:
This model only supports single tool-calls at once! (in tool_use:95)
The model had emitted two tool calls in one assistant turn; the error came on the next request, when that turn was replayed as history — so the run was already several calls deep before anything broke. Hence max_parallel_tool_calls in the adapter: only the honoured calls are echoed into the transcript, the rest are simply not claimed, and the model may ask again next turn. Nothing fabricated, history stays valid.
Part 2 — the gap in LangGraph
recursion_limit is a step cap, not a progress check. And the default is not the 25 a lot of material still assumes:
# langgraph/_internal/_config.py, langgraph 1.2.11
DEFAULT_RECURSION_LIMIT = int(getenv("LANGGRAPH_DEFAULT_RECURSION_LIMIT", "10007"))
10,007 supersteps is roughly 5,003 model calls before anything intervenes. Run to exhaustion on the standard model → tools → model loop (no API key needed, costs nothing):
--- PART A stock LangGraph ---
outcome : GraphRecursionError
model calls : 5004
tool calls : 5003 (all identical: search_orders(query='order 42'))
wall time : 122.76s
--- PART B same graph + agentfuse LoopFuse(repeat_threshold=3) ---
outcome : completed
model calls : 3
tool calls : 2
wall time : 0.01s
Reproduced against the installed library, with a live model in front of it, the stock graph makes 12 identical tool calls and then raises GraphRecursionError — an exception, not an answer. The same graph with a wrapped tool node stops at 2 and completes.
So: a patch adding an opt-in ToolNode(no_progress_limit=...). Off by default, no behaviour change when unset, and a refused call comes back as ToolMessage(status="error") so the agent can recover or stop instead of the graph blowing up.
+ no_progress_limit: If set, refuse a tool call whose (name, args) signature has
+ already been executed this many times in this request instead of running
+ it again. `recursion_limit` caps how many steps a graph may take; this
+ caps how many times it may take the *same* step.
Applied to the installed langgraph 1.2.11 and verified, 8/8:
[PASS] ToolNode accepts no_progress_limit — default None
[PASS] no_progress_limit=1 is rejected — a first call is never a repeat
[PASS] off by default: the stock behaviour is untouched — 10 identical tool calls, then GraphRecursionError
[PASS] no_progress_limit=2 runs the tool exactly 1x — tool ran 1x across 15 model turns
[PASS] no_progress_limit=3 runs the tool exactly 2x — tool ran 2x across 15 model turns
[PASS] no_progress_limit=5 runs the tool exactly 4x — tool ran 4x across 15 model turns
[PASS] an agent that keeps changing its arguments is never refused — 10 distinct tool calls, none refused
[PASS] live: the patched node stops the agent without an exception — 2 live tool calls, 1676 real tokens
That last one, side by side:
stock GraphRecursionError llm=6 tool=6 tokens=1939
final: ''
patched (no_progress_limit=3) completed llm=5 tool=2 tokens=1676
final: "Since the function is not returning the tracking number, let's try to
get the order details first."
The patched final answer is honest but not good — a small model given a refusal message produces a small model's reply. What matters is that the run ended, without an exception, after two tool calls instead of six. The site-packages file was restored afterwards, so the reproducers in the repo keep measuring stock LangGraph.
Nothing was submitted. No issue, no pull request, nothing pushed to anyone else's repository. upstream/PR_DESCRIPTION.md is a draft for a human to re-verify against current main and decide on — which is the only honest way to do this. Maintainers deserve a patch someone has actually re-checked, not an agent's output forwarded on trust.
The self-checks
run.py prints 25 checks in four sections — the package, the lessons, live, and upstream:
1. THE PACKAGE — agentfuse 0.1.0
[PASS] zero third-party imports at module level — 11 modules scanned
[PASS] pip-installable layout (pip install --target, then import it)
[PASS] pytest suite green — 85 passed in 3.59s
2. THE LESSONS — every fuse replays the run that produced it
[PASS] P4 restricted grant refuses ledger_write, allows fetch_feed
[PASS] P4 a tool that is not in the registry is denied, not run — deny-by-default
[PASS] P11 identical tool call blocked on the 3rd attempt — verdicts [True, True, False]
[PASS] NEW A,B,A,B cycle blocked while a repeat COUNTER stays silent — max signature count 2 (< threshold 3)
[PASS] P10 hard check overrules a self-score of 1.00 — mentions-discount failed; the model scored it a PASS anyway
[PASS] P7 budget pre-flight allows the cheap call, refuses the dear one
[PASS] P11 canary rolls back a 2.2x cost regression, promotes a healthy one
[PASS] every verdict is a pure function of the recorded facts — two independent replays, identical verdicts
SELF-CHECKS: 25/25 passed
Both adapters — openai_tools for any OpenAI-compatible endpoint, langgraph_guard for LangGraph's own ToolNode(wrap_tool_call=...) hook — import their framework lazily. Installing agentfuse pulls in nothing, and without the optional extra the LangGraph tests skip while the other 85 still pass. Which is rather the point of having no dependencies.
Twelve projects, one idea
The model reasons. Plain code enforces.
P4 said it about permissions, P6 about human approval, P9 about votes, P10 about rubrics, P11 about rollbacks. agentfuse is that sentence with a pyproject.toml attached.
Code, tests, reproducers and the recorded run: https://github.com/dev48v/agentic-ai-from-zero — the library and the upstream folder are under 12-give-back/.
That closes the series at 12 of 12.
Top comments (0)