Microsoft Agent Framework in C#: Tools, Sessions and Errors

Last updated: October 31, 2026 · Tested with Microsoft.Agents.AI 1.27.0, Microsoft.Extensions.AI 10.10.0, .NET 10.0.12 (SDK 10.0.401), Windows 11

Short answer: Microsoft Agent Framework is the .NET library for building AI agents: you wrap any IChatClient with AsAIAgent(), give it instructions and C# methods as tools, and call RunAsync. It runs the loop where the model asks for a tool, your method runs, and the result goes back to the model. I tested that loop on .NET 10 and found the failures tutorials skip: a stuck agent can make 41 model calls and return an empty string with no exception, and a tool error reaches the model only as Error: Function failed.

How I tested it: no API key and no local model on this machine, so the “model” in every run below is a small scripted IChatClient that returns a fixed reply and logs what the agent sent it. Everything else is the real framework: the agent, the tool loop, sessions, approvals and error handling. So these runs show how the framework behaves, not how good a model’s answers are. The OpenAI and Ollama wiring in section 1 is compile-checked only.

What happened What you see Section
Model keeps calling a tool 41 model calls, empty Text, no exception 6
Tool throws Model gets Error: Function failed. 5
Tool fails 4 times in a row Your exception comes out of RunAsync 5
Model invents a tool name Error: Requested function "CancelOrder" not found. 5
Agent forgets the last message No AgentSession passed 3
Sub-agent has no name Tool name AsAIFunction_InvokeAgent_0 7
RunAsync<T> gets plain text JsonException: 'S' is an invalid start of a value 9

1. Your First Agent in C#

Install Microsoft.Agents.AI. It works with anything that implements IChatClient from Microsoft.Extensions.AI, so the agent code doesn’t change between providers:

IChatClient openAi = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY"))
    .GetChatClient("gpt-4o-mini")
    .AsIChatClient();

IChatClient ollama = new OllamaApiClient(new Uri("http://localhost:11434"), "llama3.2");

Those two need the Microsoft.Extensions.AI.OpenAI (10.11.0) and OllamaSharp (5.5.0) packages. From there every agent looks the same:

AIAgent agent = chatClient.AsAIAgent(instructions: "You answer order questions.", name: "OrderAgent");
AgentResponse response = await agent.RunAsync("Hi");
Console.WriteLine($"  reply: {response.Text}");

2. Give the Agent Tools

A tool is a normal C# method. The descriptions matter: they’re what the model reads to decide when to call it and with what.

[Description("Gets the shipping status of an order.")]
public static string GetOrderStatus([Description("The order number.")] int orderId)
AIAgent agent = chatClient.AsAIAgent(
    instructions: "You answer order questions.",
    tools: [AIFunctionFactory.Create(OrderTools.GetOrderStatus)]);
var response = await agent.RunAsync("Where is order 1042?");

The log shows what one RunAsync does. The model is called twice, and your method runs in between:

[model call 1] 1 messages, tools: [GetOrderStatus]
[tool ran] GetOrderStatus(1042)
[model call 2] 3 messages, tools: [GetOrderStatus]
[model sees tool result] Order 1042: shipped
reply: Order 1042 has shipped.

That’s the core of an agent. It also means one question can be several paid model calls, which matters in section 6.

3. Why the Agent Forgets: Sessions

Without a session, each run starts from nothing. I told it a name and then asked for it. The second call sent the model one message, so it had no way of knowing:

[model call 1] 1 messages, tools: []
[model call 2] 1 messages, tools: []

Create an AgentSession and pass it to every run:

AgentSession session = await agent.CreateSessionAsync();
await agent.RunAsync("My name is Sam.", session);
await agent.RunAsync("What is my name?", session);
[model call 1] 1 messages, tools: []
[model call 2] 3 messages, tools: []

Now the second call carries the whole conversation. Note what that means for cost: history is sent again on every call, so long sessions get more expensive.

4. Save a Session and Restore It

In a web app the session has to outlive the request. Serialize it, store the JSON, and bring it back on the next request:

JsonElement saved = await agent.SerializeSessionAsync(session);
AgentSession restored = await agent.DeserializeSessionAsync(saved);
await agent.RunAsync("What is my name?", restored);

The restored session sent the model all 3 messages, as before. This is what’s in the JSON:

{"stateBag":{"InMemoryChatHistoryProvider":{"messages":[{"role":"user","contents":[{"$type":"text","text":"My name is Sam."}]},{"role":"assistant","contents":[{"$type":"text","text":"Nice to meet you."}]}]}}}

It’s the full conversation in plain text. Treat it like user data: if people type personal details into your agent, they’re in whatever you store this in.

5. When Tools Fail

I made the tool throw an exception whose message contains a server name. The agent doesn’t crash, and the model never sees that message:

[tool ran] GetOrderStatus(-1)
[model sees tool result] Error: Function failed.
reply: Sorry, something went wrong.

Hiding the detail is a good default, because model providers shouldn’t receive your internal errors. The downside: a wrong argument type gets the same text, so the model can’t tell “bad input, try again” from “database down”. A model-invented tool name does get a useful message:

[model sees tool result] Error: Requested function "CancelOrder" not found.

To send details to the model, configure function invocation yourself and tell the agent to use your client as it is:

IChatClient withTools = chatClient.AsBuilder()
    .UseFunctionInvocation(configure: c => c.IncludeDetailedErrors = true)
    .Build();
AIAgent agent = new ChatClientAgent(withTools, new ChatClientAgentOptions
{
    UseProvidedChatClientAsIs = true,
    ChatOptions = new() { Tools = [AIFunctionFactory.Create(OrderTools.GetOrderStatus)] },
});
[model sees tool result] Error: Function failed. Exception: Database password rejected for server sql-prod-01.

That’s exactly the leak the default prevents, so I wouldn’t turn it on. A better fix is to catch expected failures inside the tool and return a clear string, such as “Order not found”. Save exceptions for things that really are broken.

And exceptions do eventually reach you. When the tool failed every time, the loop tried 4 times and then threw my original exception out of RunAsync:

[model call 4] 7 messages, tools: [GetOrderStatus]
[tool ran] GetOrderStatus(-1)
EXCEPTION System.InvalidOperationException: Database password rejected for server sql-prod-01.

So RunAsync needs a try/catch in any web endpoint, and that message shouldn’t go to the user.

6. The Silent Loop: 41 Calls and an Empty Reply

This is the one I’d check before going live. I scripted a model that keeps calling a tool that succeeds, the way a confused model does. No error, no exception, nothing in Text:

model calls: 41, reply text: ''

The default limit allowed 40 rounds of tool calls, and then the agent just stopped. Each of those calls would be billed by a real provider, and each one sends the growing conversation again. Your app gets an empty string and can easily look like it worked.

Lower the limit, and treat an empty reply as a failure:

IChatClient withTools = scripted.AsBuilder()
    .UseFunctionInvocation(configure: c => c.MaximumIterationsPerRequest = 3)
    .Build();
model calls: 4, reply text: ''

With a limit of 3, it made 4 model calls and returned empty again, so the check for an empty reply is still your job.

7. One Agent Calling Another

AsAIFunction() turns an agent into a tool for another agent. Give it a name and description, because that’s what the calling model sees:

AIAgent specialist = specialistClient.AsAIAgent(
    name: "RefundAgent",
    description: "Answers refund policy questions.");
AIAgent main = mainClient.AsAIAgent(tools: [specialist.AsAIFunction()]);
[model call 1] 1 messages, tools: [RefundAgent]
[model call 1] 1 messages, tools: []
[model call 2] 3 messages, tools: [RefundAgent]
[model sees tool result] Refunds take 5 days.

The second line is the specialist’s own model call, so one question now costs three calls. Names are cleaned up without warning: "Refund Agent" became Refund_Agent, and an agent with no name became a tool the model can’t make sense of:

tool name: 'AsAIFunction_InvokeAgent_0'

8. Log Tool Calls and Require Approval

Middleware sees every tool call, which is the place for logging and permission checks:

AIAgent agent = chatClient.AsAIAgent(tools: [AIFunctionFactory.Create(OrderTools.GetOrderStatus)])
    .AsBuilder()
    .Use(async (agent, context, next, cancellationToken) =>
    {
        Console.WriteLine($"  [tool] {context.Function.Name} args: {JsonSerializer.Serialize(context.Arguments)}");
        var result = await next(context, cancellationToken);
        Console.WriteLine($"  [tool] result: {result}");
        return result;
    })
    .Build();
[tool] GetOrderStatus args: {"orderId":1042}
[tool] result: Order 1042: shipped

For anything that spends money or deletes data, don’t rely on the model’s judgement. Wrap the tool in ApprovalRequiredAIFunction, and the run stops before the method executes:

var refund = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(OrderTools.RefundOrder));
AIAgent agent = chatClient.AsAIAgent(tools: [refund]);
AgentSession session = await agent.CreateSessionAsync();

var response = await agent.RunAsync("Refund order 1042.", session);
var request = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().Single();
var call = (FunctionCallContent)request.ToolCall;
Console.WriteLine($"  paused: approve {call.Name}? refunds so far: {OrderTools.Refunds}");

var approved = new ChatMessage(ChatRole.User, [request.CreateResponse(true)]);
var final = await agent.RunAsync(approved, session);
paused: approve RefundOrder? refunds so far: 0
[tool ran] RefundOrder(1042)
reply: Order 1042 was refunded. refunds so far: 1

The counter shows nothing happened until the approval went back. Use the same session for both runs.

9. Structured Output and Streaming

RunAsync<T> parses the reply into your type:

AgentResponse<OrderSummary> response = await agent.RunAsync<OrderSummary>("Summarise order 1042.");
OrderSummary { OrderId = 1042, Status = Shipped }

If the reply isn’t JSON, you get a parsing exception, not an agent error. My scripted model ignored the format, and a real model can too. Note the message says nothing about agents, so it’s easy to look in the wrong place:

EXCEPTION System.Text.Json.JsonException: 'S' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0.

For a chat UI use RunStreamingAsync, which takes the same arguments:

await foreach (var update in agent.RunStreamingAsync("Where is order 1042?"))
    Console.WriteLine($"  update: {update.Text}");

Agent Framework vs Semantic Kernel

If you’re starting a new agent project, I’d start here. Microsoft publishes a migration guide from Semantic Kernel, which tells you the direction. If you already have a working Semantic Kernel app, there’s no rush: I haven’t tested a migration, so read the guide and try one agent first. And if you only need a single model call with no tools, plain IChatClient is enough; you don’t need an agent.

Agent Checklist Before Going Live

  • Set MaximumIterationsPerRequest, and treat an empty Text as a failure.
  • Wrap RunAsync in try/catch and don’t show exception messages to users.
  • Return clear strings from tools for expected failures.
  • Name and describe every tool and sub-agent.
  • Use ApprovalRequiredAIFunction for anything irreversible.
  • Pass a session on purpose, and store session JSON as user data.
  • Log tool calls with middleware; count model calls to watch cost.

Official reference: Microsoft Agent Framework documentation and the GitHub repo.

Related: build an MCP server in C# · Semantic Kernel tutorial · local AI with Ollama and C# · AI chatbot with C# · all AI in .NET tutorials

Similar Posts

One Comment

Leave a Reply

Your email address will not be published. Required fields are marked *