I thought I had a budget. I set tools on FunctionInvokingChatClient, shipped a support agent, and figured forty iterations was plenty of room for a sticky ticket. Then an offline replay of a bad day showed one chat message chewing through 41 model calls and 40 tool calls. Another scenario pushed past a million input tokens. A third issued two refunds for the same order.
Nobody wrote a while (true). The framework did what it is documented to do. The mistakes were mine.
This is not a full wiring tutorial. It is five mistakes I keep seeing (and made), with the shape of the fix for each. Stack: .NET 10 and Microsoft.Extensions.AI 10.11.0. Snippets are truncated on purpose; the trimmed parts are marked.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. The complete, working, tested project behind this post (
runaway-agent-guards-dotnet) is inside, with full source and an in-depth PDF guide. Join Tech Skill Builder
Mistake 1: Treating 40 as your spend limit
MaximumIterationsPerRequest defaults to 40. That is a fuse against an infinite tool loop. It is not a token budget, a dollar budget, or a "stop when this is the same call again" policy.
When the fuse blows, the client makes one more call with tools removed. A polite model writes text. A model that still emits a function call leaves an unanswered FunctionCallContent at the end of the history. response.Text can be empty. Nothing throws. Your API returns 200 with silence, and the trace looks fine until someone opens it.
MaximumConsecutiveErrorsPerRequest defaults to 3. The fourth consecutive tool failure rethrows the tool's own exception. In the demo that is a TimeoutException whose message includes an internal hostname. So the "safety" path can also become a 500 that leaks infrastructure detail.
Unguarded offline numbers from the project (demo prices $2.50 / $10 per million tokens):
scenario model/tool input tokens outcome Stubborn 41 / 40 20,051 answered after 40 searches PingPong 41 / 40 29,107 answered Hoarder 41 / 40 1,060,706 answered, ~$2.67 DoubleRefund 3 / 2 744 2 refunds / 50 EUR Flaky 4 / 4 986 threw TimeoutException The Hoarder is the finance pager. Each tool result is a fat export. Every iteration resends the whole conversation, so input tokens grow roughly with the square of the step count. Forty calls, one request, seven figures of tokens.
Fix shape: keep the framework cap as a backstop a couple of iterations above your limits, and put real budgets somewhere that can see every inner model round-trip. A 40 you never intended to hit is not a product decision.
Mistake 2: Matching tool calls as raw strings
The first loop detector people write is if (call == lastCall). Models almost never repeat byte for byte. They retry "order 4012", then " ORDER 4012 ", then "order 4012", with keys shuffled and 25 becoming 25.0. Exact string equality calls that "progress."
Fingerprint the call: tool name plus canonical arguments, then hash.
public static string Fingerprint(string toolName, IEnumerable<KeyValuePair<string, object?>>? args) { var canonical = toolName.Trim().ToLowerInvariant() + "|" + Canonicalize(args); return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(canonical)))[..16]; } // Canonicalize: trim/lower keys, sort them, collapse whitespace in strings, // compare numbers as decimals, recurse into nested objects. // Opinions worth locking in: array order matters; 7 and "7" stay different. // (full implementation in the complete project) Without that, your "identical call" counter never fires on the loops that actually happen in production.
Mistake 3: Only watching consecutive exact repeats
Even with fingerprints, "same call twice in a row" misses three other stuck shapes:
- Exact repeat, not necessarily consecutive. Search, open, search again with the same fingerprint. Default in the project: after
MaxIdenticalCalls(2) prior runs of that fingerprint, the next one is a loop. - Cycle.
get_order(A-17)→get_customer(C-9)→ same pair again. Period 2 or 3, repeated twice, with at least two distinct calls in the block. - Same result, different args. The Wanderer rephrases every query so fingerprints never match, but
searchkeeps returning "No matching orders." Three identical result fingerprints from one tool is still a loop. - Error streak. One tool failed twice in a row (default
MaxErrorStreak= 2).
The first two can be checked before the tool runs. The last two only after.
Stopping hard on the first hit is often too sharp. The project nudges once: the tool is not executed; the model gets a GUARD: message as the tool result. The second time that rule fires for that call, the invoker sets Terminate = true and stops. A Listener-style model takes the hint and answers. A Stubborn one ignores it and gets cut off. Either way the nudge cost nothing.
switch (decision.Action) { case GuardAction.Stop: ctx.Terminate = true; return $"GUARD STOP: {decision.Reason}. {decision.Message}"; case GuardAction.Nudge: return decision.Message; // tool NOT executed case GuardAction.ReplayResult: return guard.ReplayFor(decision.Message); } // then invoke, or swallow failures into a short detail-free error // (full invoker in the complete project) Mistake 4: Metering the outside of FunctionInvokingChatClient
Budgets for model calls, tokens, cost, and wall clock have to see every model round-trip inside the tool loop. If you wrap the outer client, you see one request. The twelve inner calls are invisible, and your "12 model calls max" never trips until it is too late—or never trips at all.
Wire the guard in two places:
FunctionInvokingChatClient FunctionInvoker = guard, iteration cap = backstop └─ MeteringChatClient check budget BEFORE each model call; record usage after └─ your IChatClient public override async Task<ChatResponse> GetResponseAsync(...) { var decision = guard.BeforeModelCall(TokenEstimator.Estimate(list)); if (decision.Action == GuardAction.Stop) return Stopped(decision); // empty assistant message, NO model call var response = await base.GetResponseAsync(list, options, cancellationToken); // record UsageDetails (or estimate) via guard.AfterModelCall(...) // (full metering client in the complete project) return response; } When the meter refuses, FunctionInvokingChatClient sees a response with no function calls and ends the loop on its own. No exception theatre.
Demo defaults in the project: 12 model calls, 20 tool calls, 60k input tokens, $0.25, 90s. Check before you spend. A post-call counter stops you past the line; a pre-call check, plus a projected input size for the next context, stops the Hoarder around 73k input tokens (wrap-up included) instead of 1.06 million.
Set MaximumIterationsPerRequest a couple above the guard. If that backstop ever fires, treat it as misconfiguration and detect the dangling unanswered function call.
Mistake 5: Treating a refund like a search
search can run twice. issue_refund should not. Unguarded DoubleRefund in the demo: the model calls issue_refund(order: "4012", amountEur: 25), then calls it again with the same arguments after a "client retry." Result: 2 refunds / 50 EUR.
Give tools a policy:
Tools = new Dictionary<string, ToolPolicy>(StringComparer.OrdinalIgnoreCase) { ["issue_refund"] = new(MaxCallsPerRun: 1, SideEffecting: true), ["export_tickets"] = new(MaxCallsPerRun: 10), }; For a side-effecting tool, a repeat with the same fingerprint is not executed again. The guard returns the stored result of the first call so the model sees success and moves on. A failed side effect is never cached, so a real retry after an error still runs. This is per run only—you still want an idempotency key at the payment provider. The guard is the first line, not the last.
Also: when a guard stops a run, make one final no-tools call (outside the loop budget) so the user gets a written answer instead of empty text or a stack trace. Strip unanswered function calls from history first; providers reject a call with no result.
What changes when the guards are on
Same offline models, same tools:
scenario model/tool input tokens stop Stubborn 5 / 2 1,388 LoopDetected (1 nudge) PingPong 7 / 3 2,255 LoopDetected Hoarder 11 / 10 73,052 BudgetTokens DoubleRefund 3 / 1 744 Completed, 1 refund Flaky 3 / 2 782 ErrorStreak Model counts include the single wrap-up call.
Quick checklist
- [ ] Treat
MaximumIterationsPerRequestas a fuse, not a budget - [ ] Fingerprint tool + canonical args (not raw JSON strings)
- [ ] Detect exact repeats, cycles, same-result, and error streaks
- [ ] Nudge once (tool skipped), then
Terminate = true - [ ] Put metering under
FunctionInvokingChatClient; check before spend - [ ] Mark refunds and other writes as side-effecting with a per-run cap
- [ ] Wrap up with one no-tools answer after a guard stop
A fixed step cap still cannot tell a legitimate long task from a stuck one that varies its queries just enough. The full project goes further on that question; this post stops where the five mistakes above already save you from the 40-call default.
Get the complete project
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. This project is already inside: runaway-agent-guards-dotnet — the full .NET 10 solution with the argument canonicalizer, loop detector, run budget, per-run guard, guarded function invoker, metering chat client, GuardedAgent, eight deterministic offline misbehaviours, a minimal API, a CLI demo, and a large offline test suite. Plus a long PDF guide that goes much deeper than this post. You also get the other complete, tested .NET + AI projects in the membership, with new ones added regularly.
Join Tech Skill Builder for $5/month
Which of these five is still true in your agent pipeline tonight?

