Building AI Agents in C# — From ReAct to Multi-Agent Systems
Build AI agents in .NET from scratch: ReAct loops with IChatClient, tool calling, planning, memory, multi-agent orchestration, and production patterns for reliable agent systems.
Building AI Agents in C# — From ReAct to Multi-Agent Systems
An AI agent is a system that uses an LLM to decide what actions to take, executes those actions (tools), observes results, and repeats until it achieves a goal. This guide builds agents in .NET from first principles up to production-grade multi-agent systems.
What Makes Something an Agent?
A simple LLM call:
Input → LLM → Output
One turn, no tools, no state
An agent:
Goal → [Think → Act → Observe] × N → Answer
Multiple turns, tools, memory, adaptive behaviourPattern 1: ReAct Loop (Hand-Rolled)
ReAct (Reason + Act) is the foundational agent pattern. The LLM reasons about what to do, calls a tool, observes the result, and continues.
public class ReActAgent(IChatClient chatClient)
{
private readonly List<ChatMessage> _history = [];
public async Task<string> RunAsync(string goal, IReadOnlyDictionary<string, Func<string, Task<string>>> tools, CancellationToken ct)
{
_history.Add(new ChatMessage(ChatRole.System, $"""
You are a helpful agent. To use a tool, respond EXACTLY in this format:
Action: <tool_name>
Input: <tool_input>
When you have the final answer, respond EXACTLY:
Final Answer: <your answer>
Available tools: {string.Join(", ", tools.Keys)}
"""));
_history.Add(new ChatMessage(ChatRole.User, goal));
for (int step = 0; step < 10; step++) // max 10 steps
{
var response = await chatClient.CompleteAsync(_history, cancellationToken: ct);
var text = response.Message.Text ?? "";
_history.Add(new ChatMessage(ChatRole.Assistant, text));
// Parse Final Answer
if (text.Contains("Final Answer:"))
return text.Split("Final Answer:")[1].Trim();
// Parse Action
if (text.Contains("Action:") && text.Contains("Input:"))
{
var toolName = Extract(text, "Action:", "Input:").Trim();
var toolInput = Extract(text, "Input:", null).Trim();
var observation = tools.TryGetValue(toolName, out var tool)
? await tool(toolInput)
: $"Tool '{toolName}' not found.";
_history.Add(new ChatMessage(ChatRole.User, $"Observation: {observation}"));
}
}
return "Max steps reached without a final answer.";
}
private static string Extract(string text, string start, string? end)
{
var idx = text.IndexOf(start) + start.Length;
if (end is null) return text[idx..].Trim();
var endIdx = text.IndexOf(end, idx);
return endIdx < 0 ? text[idx..].Trim() : text[idx..endIdx].Trim();
}
}
// Usage
var agent = new ReActAgent(chatClient);
var tools = new Dictionary<string, Func<string, Task<string>>>
{
["GetOrderStatus"] = async input =>
{
var id = int.Parse(input);
var order = await orderRepo.GetByIdAsync(id, ct);
return order is null ? "Not found" : $"Status: {order.Status}, Total: {order.Total}";
},
["GetWeather"] = async input =>
{
var weather = await weatherService.GetAsync(input, ct);
return weather.Summary;
},
};
var answer = await agent.RunAsync("What is the status of order 42?", tools, CancellationToken.None);Pattern 2: Tool Calling with Microsoft.Extensions.AI
The modern approach — the SDK handles the ReAct loop automatically.
// Define tools as C# methods
[Description("Get the status of a customer order by ID")]
static async Task<OrderStatus> GetOrderStatus(
[Description("Order ID")] int orderId,
IOrderRepository repo,
CancellationToken ct = default)
=> await repo.GetStatusAsync(orderId, ct);
[Description("Search the product catalogue")]
static async Task<List<Product>> SearchProducts(
[Description("Search query")] string query,
IProductService products,
CancellationToken ct = default)
=> await products.SearchAsync(query, ct);
[Description("Place a new order for a customer")]
static async Task<int> PlaceOrder(
[Description("Customer ID")] int customerId,
[Description("Product ID")] int productId,
[Description("Quantity")] int quantity,
IOrderService orders,
CancellationToken ct = default)
=> await orders.CreateAsync(customerId, productId, quantity, ct);
// Build agent with automatic tool invocation
public class ShoppingAgent(IChatClient chatClient, IServiceProvider sp)
{
public async Task<string> AssistAsync(string userMessage, CancellationToken ct)
{
var options = new ChatOptions
{
Tools =
[
AIFunctionFactory.Create(GetOrderStatus),
AIFunctionFactory.Create(SearchProducts),
AIFunctionFactory.Create(PlaceOrder),
],
ToolMode = ChatToolMode.Auto,
};
// With UseFunctionInvocation() middleware — runs the full loop automatically
var messages = new List<ChatMessage>
{
new(ChatRole.System, "You are a helpful shopping assistant. Use tools to help customers."),
new(ChatRole.User, userMessage),
};
var response = await chatClient.CompleteAsync(messages, options, ct);
return response.Message.Text ?? "";
}
}Pattern 3: Plan-and-Execute Agent
For complex goals: first plan all steps, then execute them in sequence or parallel.
public class PlanAndExecuteAgent(IChatClient chatClient)
{
public async Task<string> RunAsync(string goal, IReadOnlyList<AITool> tools, CancellationToken ct)
{
// Step 1: Generate a plan
var planMessages = new List<ChatMessage>
{
new(ChatRole.System, $"""
You are a planning agent. Given a goal and a list of available tools,
create a numbered step-by-step plan. Each step must be executable with one tool call.
Available tools: {string.Join(", ", tools.Select(t => t.Metadata.Name))}
"""),
new(ChatRole.User, $"Goal: {goal}"),
};
var planResponse = await chatClient.CompleteAsync(planMessages, cancellationToken: ct);
var plan = planResponse.Message.Text ?? "";
// Step 2: Execute each step
var executionHistory = new List<(string Step, string Result)>();
var steps = ParseSteps(plan);
foreach (var step in steps)
{
var execMessages = new List<ChatMessage>
{
new(ChatRole.System, "Execute this step using the available tools. Return only the result."),
new(ChatRole.User, $"Step: {step}\nContext from previous steps: {FormatContext(executionHistory)}"),
};
var execOptions = new ChatOptions { Tools = tools.ToList(), ToolMode = ChatToolMode.Required };
var execResponse = await chatClient.CompleteAsync(execMessages, execOptions, ct);
executionHistory.Add((step, execResponse.Message.Text ?? ""));
}
// Step 3: Synthesise final answer
var synthMessages = new List<ChatMessage>
{
new(ChatRole.System, "Synthesise the results of all executed steps into a final answer."),
new(ChatRole.User, $"Goal: {goal}\nResults:\n{FormatContext(executionHistory)}"),
};
var finalResponse = await chatClient.CompleteAsync(synthMessages, cancellationToken: ct);
return finalResponse.Message.Text ?? "";
}
private static List<string> ParseSteps(string plan)
=> plan.Split('\n').Where(l => l.TrimStart().StartsWith(char.IsDigit(l.TrimStart().FirstOrDefault() ? '0' : '1').ToString()[0])).ToList();
private static string FormatContext(List<(string Step, string Result)> history)
=> string.Join("\n", history.Select((h, i) => $"{i + 1}. {h.Step} → {h.Result}"));
}Agent Memory — Conversation + Semantic
// Short-term memory: conversation history (in-process)
public class AgentWithMemory(IChatClient chatClient)
{
private readonly List<ChatMessage> _conversationHistory = [
new(ChatRole.System, "You are a customer support agent with memory of the conversation."),
];
public async Task<string> ChatAsync(string userMessage, CancellationToken ct)
{
_conversationHistory.Add(new ChatMessage(ChatRole.User, userMessage));
var response = await chatClient.CompleteAsync(_conversationHistory, cancellationToken: ct);
var reply = response.Message.Text ?? "";
_conversationHistory.Add(new ChatMessage(ChatRole.Assistant, reply));
// Trim history to last 20 messages (token management)
if (_conversationHistory.Count > 22)
_conversationHistory.RemoveRange(1, _conversationHistory.Count - 21);
return reply;
}
}
// Long-term semantic memory: retrieve relevant past context
public class AgentWithSemanticMemory(
IChatClient chatClient,
IEmbeddingGenerator<string, Embedding<float>> embedder,
IVectorStore vectorStore)
{
public async Task<string> ChatAsync(string userMessage, CancellationToken ct)
{
// Retrieve semantically similar past interactions
var queryEmbedding = await embedder.GenerateAsync([userMessage], cancellationToken: ct);
var collection = vectorStore.GetCollection<string, MemoryRecord>("agent_memory");
var relevantMemories = await collection.VectorizedSearchAsync(
queryEmbedding[0].Vector, new VectorSearchOptions { Top = 3 }, ct);
var memoryContext = new StringBuilder();
await foreach (var m in relevantMemories.Results)
memoryContext.AppendLine($"Previous: {m.Record.Content}");
var messages = new List<ChatMessage>
{
new(ChatRole.System, $"You are a support agent. Relevant history:\n{memoryContext}"),
new(ChatRole.User, userMessage),
};
var response = await chatClient.CompleteAsync(messages, cancellationToken: ct);
// Store new interaction in memory
var embedding = await embedder.GenerateAsync([$"{userMessage} {response.Message.Text}"], ct);
await collection.UpsertAsync(new MemoryRecord
{
Id = Guid.NewGuid().ToString(),
Content = $"User: {userMessage}\nAssistant: {response.Message.Text}",
Timestamp = DateTime.UtcNow,
Embedding = embedding[0].Vector,
}, ct);
return response.Message.Text ?? "";
}
}Production: Agent with Observability and Reliability
public class ProductionAgent(
IChatClient chatClient,
ILogger<ProductionAgent> logger,
IDistributedCache cache)
{
public async Task<AgentResult> RunWithReliabilityAsync(
string sessionId,
string userMessage,
CancellationToken ct)
{
using var activity = Telemetry.Agent.StartActivity("AgentRun");
activity?.SetTag("session.id", sessionId);
activity?.SetTag("message.length", userMessage.Length);
// Check cache for identical recent queries
var cacheKey = $"agent:{sessionId}:{userMessage.GetHashCode()}";
var cached = await cache.GetStringAsync(cacheKey, ct);
if (cached is not null)
{
logger.LogInformation("Agent cache hit for session {SessionId}", sessionId);
return new AgentResult(cached, fromCache: true);
}
try
{
var response = await chatClient.CompleteAsync(
[new ChatMessage(ChatRole.User, userMessage)],
cancellationToken: ct);
var result = response.Message.Text ?? "";
await cache.SetStringAsync(cacheKey, result,
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }, ct);
activity?.SetTag("response.tokens", response.Usage?.OutputTokenCount ?? 0);
logger.LogInformation("Agent completed in session {SessionId}, tokens: {Tokens}",
sessionId, response.Usage?.OutputTokenCount ?? 0);
return new AgentResult(result, fromCache: false);
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
logger.LogError(ex, "Agent failed in session {SessionId}", sessionId);
throw;
}
}
}
public record AgentResult(string Response, bool FromCache);Interview Answer
"An AI agent uses an LLM to decide what to do, executes tools, observes results, and repeats — the ReAct (Reason+Act) pattern. In .NET, there are two approaches: hand-rolled (maintain a ChatMessage list, parse tool calls from the LLM response, append observations, repeat) or via Microsoft.Extensions.AI with AIFunctionFactory.Create — the SDK handles the loop when UseFunctionInvocation() middleware is registered. For complex goals, Plan-and-Execute generates a full plan first, then executes steps sequentially, which is more reliable than a single agent deciding one step at a time. Memory has two layers: short-term (conversation history list, trimmed to last N messages for token control) and long-term (semantic vector search over past interactions using IEmbeddingGenerator + IVectorStore). Production agents need observability (ActivitySource traces, token count logging), response caching (identical prompts in Redis), and a step limit to prevent infinite loops. Semantic Kernel's AgentGroupChat handles multi-agent scenarios with pluggable selection and termination strategies."
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.