5 Mistakes That Let a .NET AI Agent Burn Through 40 Tool Calls
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 p

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
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 Enter fullscreen mode Exit fullscreen mode 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.
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) Enter fullscreen mode Exit fullscreen mode Without that, your "identical call" counter never fires on the loops that actually happen in production.
Even with fingerprints, "same call twice in a row" misses three other stuck shapes:
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) Enter fullscreen mode Exit fullscreen mode 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.
FunctionInvokingChatClient FunctionInvoker = guard, iteration cap = backstop └─ MeteringChatClient check budget BEFORE each model call; record usage after └─ your IChatClient Enter fullscreen mode Exit fullscreen mode 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; } Enter fullscreen mode Exit fullscreen mode 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.
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.
Tools = new Dictionary < string, ToolPolicy >( StringComparer. OrdinalIgnoreCase ) { [ "issue_refund" ] = new ( MaxCallsPerRun: 1, SideEffecting: true ), [ "export_tickets" ] = new ( MaxCallsPerRun: 10 ), }; Enter fullscreen mode Exit fullscreen mode 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.
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 Enter fullscreen mode Exit fullscreen mode Model counts include the single wrap-up call.
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.
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.
Which of these five is still true in your agent pipeline tonight?