“Agent” has become a word that means everything, so here is the narrow, useful meaning: a language model that can call your code, in a loop, until it has an answer. You describe a few functions to the model; it decides which to call and with what arguments; your program runs them and sends the results back; the model either calls more or answers. That’s it.
In .NET, Microsoft.Extensions.AI gives you that loop out of the box, behind an IChatClient interface that works with OpenAI, Azure OpenAI, Ollama and others. The loop is the easy part. What makes an agent safe to put in front of customers is what surrounds it, and that’s what this article is about.
To keep it honest, everything below ran for real: Microsoft.Extensions.AI 10.11 on .NET 10, against Qwen 2.5 3B, a small open model, served locally by Ollama on a 4-core machine with no GPU. A small model makes the weak points visible, which is exactly what you want when designing guardrails.
The agent: a support assistant for a shop
The example is a support assistant with two tools: one looks an order up, the other refunds an item. Tools are plain C# methods; the descriptions are what the model reads to decide when and how to call them:
[Description("Looks an order up by its id and returns its status and items.")]
public string GetOrder([Description("The order id, such as A-1001.")] string orderId) =>
_orders.TryGetValue(orderId, out var o)
? $"Order {o.Id}: {o.Status}; items: {string.Join(", ", o.Items.Select(i => $"{i.Name} €{i.Price}"))}"
: $"No order {orderId}.";
[Description("Refunds one item of a delivered order.")]
public string RefundItem(
[Description("The order id.")] string orderId,
[Description("The item's name, as GetOrder lists it.")] string item,
[Description("Why the customer wants the refund.")] string reason)
{
if (!_orders.TryGetValue(orderId, out var o)) return $"No order {orderId}.";
if (o.Status != "delivered") return $"Order {orderId} isn't delivered yet; it can't be refunded.";
var line = o.Items.FirstOrDefault(i => i.Name.Equals(item, StringComparison.OrdinalIgnoreCase));
if (line is null) return $"Order {orderId} has no item '{item}'.";
return $"Refunded €{line.Price} for {line.Name} in {orderId}.";
}
The client is a pipeline, like ASP.NET Core middleware. UseFunctionInvocation adds the loop:
IChatClient model = new OpenAIClient(new ApiKeyCredential("unused"),
new OpenAIClientOptions { Endpoint = new Uri("http://localhost:11434/v1") })
.GetChatClient("qwen2.5-3b")
.AsIChatClient();
IChatClient agent = new ChatClientBuilder(model)
.UseFunctionInvocation(configure: f => f.MaximumIterationsPerRequest = 5)
.Build();
var options = new ChatOptions
{
Temperature = 0,
Tools =
[
AIFunctionFactory.Create(orders.GetOrder),
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(orders.RefundItem)),
],
};
AIFunctionFactory.Create reads the method’s signature and [Description] attributes and builds the JSON schema the model sees. Swapping Ollama for Azure OpenAI changes only the first statement.
A first question, with each tool call logged:
> Where is my order A-1001?
tool GetOrder(orderId: A-1001) → Order A-1001: shipped, arriving Thursday; items: Desk lamp €39.90 [4 ms]
< Your order A-1001 is shipped and will arrive on Thursday. The items are a Desk lamp priced at €39.90. [10.3 s]
The model didn’t know anything about order A-1001. It chose the tool, filled in the argument, read the result and answered from it.
Guardrail 1: rules live in code, not in the prompt
The system prompt says to look orders up before acting. A small model doesn’t always listen. Asked to refund an item from an order that hasn’t arrived, it went straight for the refund without checking:
> Please refund the desk lamp in order A-1001, I changed my mind.
tool RefundItem(orderId: A-1001, item: Desk Lamp, reason: Changed my mind) → Order A-1001 isn't delivered yet; it can't be refunded.
< It seems that the order A-1001 hasn't been delivered yet, so we can't process the refund for the desk lamp. Please check back later.
Nothing bad happened, because the rule was in the tool, not in the prompt. The tool checked the status, matched the item by name against the real order, and took the price from the order rather than from the model. Treat every argument from the model like input from an untrusted form: validate it, look it up, and never let the model supply a number you can look up yourself.
The prompt is for behaviour (tone, when to use which tool). Anything that must never happen belongs in code the model can’t talk its way around.
Advertisement
Guardrail 2: approval before actions that change things
Reading an order is harmless; a refund moves money. Wrapping a tool in ApprovalRequiredAIFunction makes the loop stop before running it and hand back a ToolApprovalRequestContent instead. Your code, or a person, decides:
var response = await agent.GetResponseAsync(history, options);
history.AddRange(response.Messages);
foreach (var request in response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>())
{
var call = (FunctionCallContent)request.ToolCall;
var approve = call.Name != "RefundItem" || await refundPolicy.AllowsAsync(call);
history.Add(new ChatMessage(ChatRole.User, [request.CreateResponse(approve)]));
}
response = await agent.GetResponseAsync(history, options); // the loop resumes
With the refund approved:
> The coffee mug in order A-1002 arrived broken. Can I get my money back for it?
approval needed: GetOrder(orderId: A-1002) → approved
approval needed: RefundItem(orderId: A-1002, item: coffee mug, reason: arrived broken) → approved
tool GetOrder(orderId: A-1002) → Order A-1002: delivered; items: Coffee mug €12.50, Tea towel €6.00
tool RefundItem(orderId: A-1002, item: coffee mug, reason: arrived broken) → Refunded €12.50 for Coffee mug in A-1002.
< Your refund for the broken coffee mug has been processed.
One thing surprised me in that log: GetOrder was sent for approval too, though it isn’t marked. The model asked for both calls in the same turn, and when any call in a turn needs approval, the whole turn waits for it. So the approval handler must expect harmless calls as well and wave them through, as the call.Name != "RefundItem" check above does.
With the refund rejected, the lookup still ran and the money didn’t move:
approval needed: GetOrder(orderId: A-1002) → approved
approval needed: RefundItem(orderId: A-1002, item: coffee mug, reason: arrived broken) → rejected
tool GetOrder(orderId: A-1002) → Order A-1002: delivered; items: Coffee mug €12.50, Tea towel €6.00
< It seems the order has already been delivered. Please contact customer support for assistance.
The guardrail held, but look at the answer: it’s confusing, and a larger model would phrase it better. That’s the other lesson of running a small model: when the outcome matters to the customer (refused, pending, escalated), write that message in code and let the model handle only the conversation around it.
Guardrail 3: a cap on the loop
A model can get stuck calling a tool over and over: a status that never changes, an argument it keeps getting wrong. MaximumIterationsPerRequest (40 by default) caps the round trips. I tested the cap with a scripted client, a fake IChatClient that asks for the same tool on every turn:
tool ran 5 times; last message: assistant with 1 content(s) [FunctionCallContent]
Note what the cap does: it doesn’t throw. It returns the response with the last tool call unanswered. Check for that and treat it as a failure (“I couldn’t finish this, a person will follow up”) rather than showing the customer an empty answer. Set the cap to what your scenarios need; a support assistant rarely needs more than a handful of calls. MaximumConsecutiveErrorsPerRequest (3 by default) does the same for tools that keep throwing.
Testing without a model
The same fake client is how agent code gets tested. Most of an agent’s logic doesn’t involve the model at all:
- Tools are plain methods.
RefundItemgets ordinary unit tests: undelivered orders, unknown items, the price taken from the order. - The orchestration takes an
IChatClient. A scripted client that returns predetermined tool calls tests the approval flow, the iteration cap and the “unfinished” handling, deterministically and in milliseconds. - Model behaviour gets a small evaluation set. A few dozen real questions with the tool calls you expect, run against the real model before changing the model, the prompt or a tool’s description. It’s slow and not deterministic, so it runs on demand, not on every build.
Seeing what it did
Every call in the logs above came from one hook, FunctionInvoker, which wraps each tool invocation:
.UseFunctionInvocation(configure: f => f.FunctionInvoker = async (ctx, ct) =>
{
var result = await ctx.Function.InvokeAsync(ctx.Arguments, ct);
logger.LogInformation("Tool {Tool}({Args}) → {Result}", ctx.Function.Name, ctx.Arguments, result);
return result;
})
In production, add .UseOpenTelemetry() to the builder and the model calls and tool calls show up as spans, with token counts, next to the rest of the request’s trace. When a customer asks why the assistant did something, the trace is the answer.
What I’d keep in mind
- An agent is a loop, not magic. Model, tools, loop;
Microsoft.Extensions.AIprovides all three behind interfaces you can swap and fake. - Put the rules in the tools. Validate every argument, look values up instead of trusting them, return clear errors the model can relay.
- Require approval for anything that changes state, and expect the approval step to see harmless calls too.
- Cap the loop and handle the unfinished case explicitly.
- Write the messages that matter in code. Let the model handle the conversation, not the outcome.
- Test with a fake client, and keep a small evaluation set for the model itself.
A small local model was enough to show every one of these. A bigger one makes fewer mistakes, but it doesn’t remove the need for any of them.


