Skip to main content

Command Palette

Search for a command to run...

LLM Tool Call Loops in .NET: Root Cause and Fix

Updated
12 min readView as Markdown
LLM Tool Call Loops in .NET: Root Cause and Fix

An LLM tool call loop is one of the few production failures in a .NET AI endpoint that costs you money while looking completely healthy. There is no exception. There is no 500. There is a request that takes forty seconds instead of three, returns a slightly odd answer, and quietly called LookupOrder thirty-one times with the same argument on the way there. Multiply that by the handful of users who happen to phrase their question the wrong way and the monthly provider bill moves in a way nobody can explain from the dashboards.

I have debugged this on two separate support APIs now, and both times the root cause was not the model being stupid. It was a tool returning something the model could not act on. If you want the full defensive setup - the instrumented pipeline, the loop detector, and the tests that prove a misbehaving tool cannot run away - the annotated implementation lives on Patreon.

Getting tool calling right is much less about the plumbing than about the contract each tool exposes to the model. Chapter 11 of the AI-Powered .NET APIs course builds exactly that - AIFunctionFactory, automatic function invocation, wiring real services in as tools, and the safety rails around which tools you expose - inside one running ASP.NET Core support API rather than a toy console app.

AI-Powered .NET APIs

What a Tool Call Loop Looks Like in Production

When you register tools with Microsoft.Extensions.AI and call .UseFunctionInvocation(), you get a FunctionInvokingChatClient in the pipeline. Its job is described plainly in the docs: when the inner client responds with a function call, it invokes the function, sends the result back, and repeats. That loop continues "until there are no more function calls to make, or until another stop condition is met".

One request from your code becomes N requests to the provider. You pay for the full conversation history on every one of them, so cost does not grow linearly with iterations - it grows roughly with the square, because turn 20 re-sends everything from turns 1 through 19.

The symptoms, in the order they usually get noticed:

  • Latency outliers with no infrastructure cause. Your p50 is fine, your p99 is 30x worse, and nothing in the database traces explains it.

  • Token spend that does not match request volume. Input tokens climb far faster than the number of conversations.

  • A tool's own logs showing the same call repeatedly within one trace, with identical arguments.

  • Answers that end abruptly or read as if the model gave up, because it did.

Why the Loop Happens: Five Root Causes

1. The Tool Returns Ambiguous Prose

This is the number one cause, and it is entirely our fault as developers, not the model's.

A tool that returns "Could not find it." has told the model nothing actionable. Is that a transient failure worth retrying? A wrong argument worth reformulating? A terminal answer? The model guesses, and a plausible guess is "try again with a slightly different reference".

// Ambiguous: the model cannot distinguish "no such order" from "try again".
[Description("Looks up an order by its reference.")]
string GetOrder(string reference) =>
    repo.Find(reference)?.Summary ?? "Could not find it.";
// Unambiguous: a terminal state, expressed as data.
record OrderLookup(bool Found, string Detail);

[Description("Looks up an order by its reference.")]
OrderLookup GetOrder(string reference) =>
    repo.Find(reference) is { } order
        ? new OrderLookup(true, order.Summary)
        : new OrderLookup(false, "No order exists with this reference.");

Returning a typed result rather than a sentence gives the model a fact instead of an interpretation. In my experience this single change eliminates most loops before you touch any limits.

2. ToolMode Is Set to RequireAny

ChatOptions.ToolMode accepts ChatToolMode.Auto, ChatToolMode.None, ChatToolMode.RequireAny, and ChatToolMode.RequireSpecific(name). RequireAny means tool usage is required and any tool may be selected - on every turn of the loop.

Think about what that implies. The model is structurally forbidden from producing a final text answer, so the only way the loop can end is by hitting the iteration cap. Teams reach for RequireAny to stop the model answering from memory instead of calling the tool, and it works for exactly one turn before becoming a guaranteed runaway.

Use RequireSpecific for a single forced call, then let subsequent turns fall back to Auto.

3. The Tool Succeeds While Doing Nothing

A tool that returns an empty string, an empty collection, or null reads to the model as a non-answer. It will frequently try again with a variation, and since your tool succeeds every time it never trips any error handling.

Empty is a legitimate result. Say so explicitly: new SearchResult(Matches: 0, "No documents matched this query.") terminates the loop where [] invites another attempt.

4. Failing Tools Get Retried Automatically

By design, FunctionInvokingChatClient does not stop on the first tool exception. The docs are explicit that it "continues to make requests to the inner client, optionally supplying exception information", so the model can recover by trying different parameters. That is genuinely useful behaviour, and MaximumConsecutiveErrorsPerRequest bounds it at a documented default of 3.

The trap is that the counter is for consecutive errors. A tool that alternates between failing and returning something useless never accumulates three in a row, so the guard never fires.

5. Two Tools That Hand Off to Each Other

The most entertaining failure mode. GetCustomer says it needs an order ID, GetOrder says it needs a customer ID, and the model bounces between them because each tool's description implies the other is the prerequisite. This is a prompt and schema design bug that only shows up when tools are composed, which is why it survives unit testing.

How Do You Diagnose an LLM Tool Call Loop?

You cannot fix what you cannot see, and the default logging shows you one outer request. Three things make loops visible immediately:

  1. Emit GenAI telemetry. Microsoft.Extensions.AI supports OpenTelemetry through .UseOpenTelemetry() in the client pipeline. Each provider round trip becomes its own span, so a looping request renders as an obvious staircase of 30 spans instead of one.

  2. Log the tool name plus a hash of the arguments, per invocation. Identical name plus identical argument hash, twice in one trace, is a loop. That single log line is worth more than any dashboard.

  3. Record iterations per request as a histogram. Healthy tool use is 1 to 3 iterations. Anything with a tail at your configured maximum is not a slow request, it is a request that got cut off.

The last point matters because hitting the cap is silent. The response comes back, the user gets something, and unless you are counting iterations you will never know how close you ran to the edge. Our runaway LLM costs in a .NET API walkthrough covers the spend side of the same instrumentation.

The Fix, in Layers

No single setting solves this. Four layers, cheapest first.

Layer 1: Lower the Iteration Cap

FunctionInvokingChatClient.MaximumIterationsPerRequest defaults to 40. That default exists so complex agentic workflows are not cut short, and it is far too generous for a typical support or lookup endpoint. The docs note the value must be at least one, since it includes the initial request.

// Microsoft.Extensions.AI 10.x
chatClientBuilder.UseFunctionInvocation(configure: client =>
{
    client.MaximumIterationsPerRequest = 5;        // default: 40
    client.MaximumConsecutiveErrorsPerRequest = 0; // default: 3
});

Setting MaximumConsecutiveErrorsPerRequest to zero is documented to make any function-calling exception terminate the loop immediately and rethrow to the caller. For an endpoint where a failing tool means a broken dependency rather than a bad argument, that is the behaviour you want: fail fast, surface a real error, and stop paying for the model to guess.

Pick the cap from the deepest legitimate call chain your endpoint has, plus one. If your support endpoint's worst case is "look up customer, look up their order, check shipment", that is three, so five is a sane ceiling. Above ten, the problem is your prompt, not your limit.

Layer 2: Make Every Tool Result Terminal

Go through each registered tool and answer one question for each: can the model tell, from this return value alone, whether it should stop? If the answer is no, change the return type. Records with an explicit success flag beat strings. Enumerated status values beat free text. Counts beat empty collections.

Layer 3: Constrain Tool Selection Deliberately

Default to ChatToolMode.Auto. Reach for RequireSpecific when you genuinely need one named call. Treat RequireAny as something you use for a single scripted turn and never for a conversational loop. Setting ChatOptions.AllowMultipleToolCalls to false is also worth considering when parallel calls make your traces hard to read.

Layer 4: Budget the Request, Not Just the Loop

An iteration cap bounds the count of round trips, not their cost. A loop of five turns over a 30,000-token context is more expensive than twenty turns over 500 tokens. Track cumulative tokens per request and abort on budget, independently of iteration count. Pair it with an overall request timeout so a slow loop cannot hold an ASP.NET Core request thread group hostage.

What Hitting the Cap Actually Costs

Work it through with round numbers, because the shape matters more than the exact figures. Take a conversation with a 2,000-token base context where each tool result adds roughly 300 tokens. At the default of 40 iterations, the final turn re-sends about 13,700 input tokens, and the cumulative input across all 40 turns comes to roughly 314,000 tokens - for a single user request that produced no useful answer.

Drop the cap to 5 and the same runaway costs about 13,000 cumulative input tokens. That is a 24x reduction in the blast radius of one badly designed tool, from one line of configuration. Your own numbers will differ with model and context size; the quadratic shape will not.

Preventing It From Coming Back

Five habits that keep this from recurring, in the order we adopted them:

  1. Set the iteration cap explicitly in code, even if you choose 40. An explicit value is a decision; an implicit one is an accident waiting to be discovered in a bill.

  2. Add an iterations-per-request metric and alert on the tail, not the average.

  3. Review tool return types in code review the way you review public API contracts, because that is what they are.

  4. Write a test per tool for the not-found path. Assert the return value is unambiguous. This is cheap and catches cause number one before it ships.

  5. Cap the blast radius per user with token-based rate limiting, so one pathological conversation cannot consume the whole budget.

Tool design and tool security pull in the same direction here: the smallest, clearest, most tightly scoped tool surface is both the cheapest and the safest. Our guide to securing LLM tool calling in ASP.NET Core covers the least-privilege side, and if you are moving from single-call tool use toward genuine agent loops, AI agents in ASP.NET Core with Microsoft Agent Framework covers when that step is actually justified.

Frequently Asked Questions

What is the default MaximumIterationsPerRequest in Microsoft.Extensions.AI?

The documented default for FunctionInvokingChatClient.MaximumIterationsPerRequest is 40, and the value must be at least one because it includes the initial request. For a typical lookup or support endpoint that is far higher than you need. Set it explicitly to the deepest legitimate tool chain your endpoint requires, plus one.

Why does my LLM keep calling the same tool over and over in .NET?

Almost always because the tool's return value is not terminal. Prose like "could not find it", an empty collection, a null, or an empty string all read to the model as a non-answer, so it retries with a variation. Return a typed result with an explicit success flag and a plain statement of the outcome, and the loop usually stops without any configuration change.

Does lowering MaximumIterationsPerRequest break legitimate agent workflows?

It can, which is why you size the cap to your actual call graph instead of picking a number. Measure iterations per request in production first, then set the cap just above the observed legitimate maximum. Multi-step agent workflows genuinely need higher values than a single-purpose endpoint, so configure them separately rather than applying one global limit.

How do I detect a tool call loop before it reaches production?

Instrument the pipeline with OpenTelemetry so each provider round trip is its own span, log the tool name with a hash of its arguments on every invocation, and record iterations per request as a histogram. In tests, register a counting wrapper around each tool and assert the invocation count for known scenarios. Repeated identical calls inside one trace is the signal.

Should MaximumConsecutiveErrorsPerRequest be set to zero?

Set it to zero when a tool exception means a real dependency failure that the model cannot recover from by retrying, which is the common case for database and internal-service tools. Keep the default of 3 when your tools genuinely fail on bad arguments that the model can correct. Note the counter only tracks consecutive errors, so a tool alternating between failing and returning something useless will never trip it.


About the Author

I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.

More from this blog

C

Coding Droplets

324 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.