Learnixo
Back to blog
Backend Systemsadvanced

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.

Asma Hafeez KhanMay 25, 20267 min read
.NETC#AI agentsSemantic KernelMicrosoft.Extensions.AItool callingLLM
Share:𝕏

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 behaviour

Pattern 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.

C#
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.

C#
// 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.

C#
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

C#
// 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

C#
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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.