Back to Portfolio

Building Self-Referential Agents with .NET 10 & Aspire

Part 1: The Infrastructure of Intent (January 2025 Edition)

Series: From Prototype to Production - Building the PMCR-O Framework
Author: Refactoring Tutorial Agent
Target Audience: Senior .NET developers interested in local AI agents

Introduction

Welcome to Part 1 of our journey building a self-referential agent system. By the end of this tutorial, you'll have:

  • A running .NET 10 + Aspire infrastructure
  • Ollama configured with GPU support
  • Your first AI agent (The Planner) that outputs structured JSON natively
  • A working understanding of why we do things this way

What makes this different from other tutorials?

Most AI agent tutorials show you how to build a chatbot. We're building something more ambitious: an agent that plans software, writes code, tests it, and learns from its mistakes. This is the foundation for that system.

Why This Architecture?

Before we write code, let's understand the design philosophy:

The Three Pillars

  1. Local-First: We use Ollama (not OpenAI) because:
    • No API costs
    • Data privacy
    • Deterministic behavior for testing
    • You control the model lifecycle
  2. Aspire Orchestration: We use .NET Aspire because:
    • Service discovery (agents find each other automatically)
    • Built-in observability (OpenTelemetry)
    • Container lifecycle management
    • Developer experience (one F5 to run everything)
  3. Microsoft Agent Framework: We use this because:
    • Native .NET integration
    • Tool calling support
    • Workflow orchestration
    • Production-ready error handling

The Critical Insight: "I AM" vs "You Are"

This is the most important concept in this tutorial.

Traditional prompt engineering says: "You are a helpful assistant."

We say: "I AM the Planner. I speak in first person."

Why does this matter? When an agent speaks in first person, it develops agency — a sense of ownership over its decisions. Research shows that LLMs perform better when they think of themselves as actors rather than tools.

Text
❌ BAD: "You are a planner. Generate a plan for the user."
✅ GOOD: "I AM the Planner. I analyze requirements and create minimal viable plans."

Prerequisites

Software Requirements

Bash
# .NET 10 SDK
dotnet --version  # Should show 10.0.x or higher

# Docker Desktop (for Ollama container)
docker --version  # Should show 20.x or higher

# Ollama (for local LLM inference)
# We'll install this via Aspire, but you can also run locally:
# ollama --version

Hardware Requirements

  • Minimum: 16GB RAM, modern CPU
  • Recommended: 32GB RAM, NVIDIA GPU with 8GB+ VRAM
  • Ideal: 64GB RAM, RTX 4090 or similar

GPU Note: Ollama can run on CPU, but it's slow (30-60s per inference). GPU reduces this to 2-5s.

Project Setup

Step 1: Create Solution Structure

Bash
# Create solution
mkdir PmcroAgents
cd PmcroAgents
dotnet new sln -n PmcroAgents

# Create projects (matching your actual PMCR-O architecture)
# AppHost - Console app with Aspire packages (or use: dotnet new aspire-apphost if available)
dotnet new console -n PmcroAgents.AppHost
dotnet new webapi -n PmcroAgents.PlannerService
dotnet new classlib -n PmcroAgents.ServiceDefaults
dotnet new classlib -n PmcroAgents.Shared

# Add projects to solution
dotnet sln add PmcroAgents.AppHost/PmcroAgents.AppHost.csproj
dotnet sln add PmcroAgents.ServiceDefaults/PmcroAgents.ServiceDefaults.csproj
dotnet sln add PmcroAgents.Shared/PmcroAgents.Shared.csproj
dotnet sln add PmcroAgents.PlannerService/PmcroAgents.PlannerService.csproj

Step 2: Install Packages

Bash
# AppHost packages
cd PmcroAgents.AppHost
dotnet add package Aspire.Hosting.AppHost
dotnet add package CommunityToolkit.Aspire.Hosting.Ollama

# PlannerService packages
cd ../PmcroAgents.PlannerService
dotnet add package Microsoft.Agents.AI
dotnet add package Microsoft.Extensions.AI
dotnet add package OllamaSharp
dotnet add package Grpc.AspNetCore

# Shared packages
cd ../PmcroAgents.Shared
dotnet add package Microsoft.Extensions.AI
dotnet add package Grpc.Tools

Step 3: Project References

Bash
cd ../PmcroAgents.PlannerService
dotnet add reference ../PmcroAgents.Shared/PmcroAgents.Shared.csproj
dotnet add reference ../PmcroAgents.ServiceDefaults/PmcroAgents.ServiceDefaults.csproj

cd ../PmcroAgents.AppHost
dotnet add reference ../PmcroAgents.PlannerService/PmcroAgents.PlannerService.csproj

Your solution structure should look like this:

PmcroAgents/
├── PmcroAgents.sln
├── PmcroAgents.AppHost/
│   ├── Program.cs
│   └── PmcroAgents.AppHost.csproj
├── PmcroAgents.PlannerService/
│   ├── Program.cs
│   ├── Services/
│   │   └── PlannerAgent.cs
│   └── PmcroAgents.PlannerService.csproj
└── PmcroAgents.Shared/
    ├── Protos/
    │   └── agent.proto
    └── PmcroAgents.Shared.csproj

The Aspire AppHost

The AppHost is the orchestrator of our system. It defines which services run, how they connect, and resource requirements.

Modern Aspire Configuration (2025 Standards)

PmcroAgents.AppHost/Program.cs:

C#
using CommunityToolkit.Aspire.Hosting.Ollama;

var builder = DistributedApplication.CreateBuilder(args);

// ==============================================================================
// OLLAMA - LOCAL LLM SERVER
// ==============================================================================

var ollama = builder.AddOllama("ollama", port: 11434)
    .WithDataVolume()                              // Persist models between runs
    .WithLifetime(ContainerLifetime.Persistent)    // Keep container running
    .WithContainerRuntimeArgs("--gpus=all");       // Enable GPU passthrough

// ==============================================================================
// LLM MODELS - THE COUNCIL
// ==============================================================================

var qwen = ollama.AddModel("qwen2.5-coder:7b");    // The Planner

// ==============================================================================
// AGENT SERVICES
// ==============================================================================

var plannerService = builder.AddProject<Projects.PmcroAgents_PlannerService>("planner")
    .WithReference(ollama)
    .WaitFor(qwen);

builder.Build().Run();

What's happening here?

  1. Ollama Container: Aspire automatically pulls and runs the Ollama Docker image
  2. GPU Passthrough: --gpus=all gives Ollama access to your NVIDIA GPU
  3. Model Pull: .AddModel("qwen2.5-coder:7b") downloads the model (7.4GB) on first run
  4. Service Discovery: .WithReference(ollama) automatically injects the Ollama connection string into PlannerService

Why qwen2.5-coder?

  • Best open-source code model as of Dec 2024
  • 7B parameters = fast inference (2-5s on GPU)
  • Strong tool-calling compliance
  • Works well with structured output

First Agent: The Planner

gRPC Service Definition

First, we define the contract for agent communication. PmcroAgents.Shared/Protos/agent.proto:

Protobuf
syntax = "proto3";

option csharp_namespace = "PmcroAgents.Shared.Grpc";

package agent;

service AgentService {
  rpc ExecuteTask (AgentRequest) returns (AgentResponse);
}

message AgentRequest {
  string intent = 1;
  string context_json = 2;
}

message AgentResponse {
  string content = 1;
  bool success = 2;
}

Planner Service Configuration

PmcroAgents.PlannerService/Program.cs:

C#
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Http.Resilience;
using OllamaSharp;
using PmcroAgents.PlannerService.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddGrpc();

// ==============================================================================
// OLLAMA CLIENT SETUP
// ==============================================================================

var ollamaUri = builder.Configuration.GetConnectionString("ollama") 
    ?? "http://localhost:11434";
var modelId = "qwen2.5-coder:7b";

builder.Services.AddHttpClient("ollama", client =>
{
    client.BaseAddress = new Uri(ollamaUri);
    client.Timeout = Timeout.InfiniteTimeSpan;  // LLM inference can take 30s-2min on CPU
})
.AddStandardResilienceHandler(options =>
{
    options.AttemptTimeout.Timeout = TimeSpan.FromMinutes(3);
    options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(5);
    options.Retry.MaxRetryAttempts = 2;
});

// ==============================================================================
// CHAT CLIENT REGISTRATION
// ==============================================================================

builder.Services.AddSingleton<IChatClient>(sp =>
{
    var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient("ollama");
    var baseClient = new OllamaApiClient(httpClient, modelId);
    
    return new ChatClientBuilder(baseClient)
        .UseFunctionInvocation()  // Enables tool calling
        .Build();
});

var app = builder.Build();

app.MapGrpcService<PlannerAgent>();
app.MapGet("/", () => "Planner Agent - gRPC endpoint available");

app.Run();

Native Structured Output

This is where we diverge from the prototype.

The Old Way (Golden Hammer)

The prototype used a "bracket counter" algorithm to extract JSON from text:

C#
// ❌ DON'T DO THIS
var jsonBlocks = ExtractJsonBlocksUsingBracketCounter(text);
foreach (var block in jsonBlocks)
{
    try { var parsed = JsonDocument.Parse(block); }
    catch { /* hope for the best */ }
}

The Modern Way (Native JSON Mode)

Ollama supports structured output via JSON schema. Here's how PmcroAgents.PlannerService/Services/PlannerAgent.cs works:

C#
public override async Task<AgentResponse> ExecuteTask(
    AgentRequest request,
    ServerCallContext context)
{
    _logger.LogInformation("🧭 I AM the Planner. I am analyzing: {Intent}", request.Intent);

    var chatOptions = new ChatOptions
    {
        // This is the magic: tell Ollama to output ONLY JSON
        ResponseFormat = ChatResponseFormat.Json,
        
        // Optional: Provide a schema for validation
        AdditionalProperties = new Dictionary<string, object?>
        {
            ["schema"] = JsonSerializer.Serialize(new
            {
                type = "object",
                properties = new
                {
                    plan = new { type = "string", description = "The implementation plan" },
                    steps = new
                    {
                        type = "array",
                        items = new
                        {
                            type = "object",
                            properties = new
                            {
                                action = new { type = "string" },
                                rationale = new { type = "string" }
                            }
                        }
                    },
                    estimated_complexity = new { type = "string", @enum = new[] { "low", "medium", "high" } }
                },
                required = new[] { "plan", "steps" }
            })
        }
    };
    
    // ... execution logic ...
}

Why is this better?

Aspect Old Way (Golden Hammer) New Way (Native JSON)
Reliability ~85% success rate ~99% success rate
Performance 50-200ms parsing overhead <1ms deserialization
Code Complexity 200+ lines of parsing logic 0 lines
Maintainability Fragile, breaks on edge cases Robust, schema-enforced

Testing the Infrastructure

Step 1: Start the System

Bash
cd PmcroAgents.AppHost
dotnet run

Aspire dashboard launches at http://localhost:15209. Service discovery maps ollama:11434 → actual container IP.

Step 2: Test the Planner

Create a test client PmcroAgents.Tests/PlannerTests.cs:

C#
[Fact]
public async Task Planner_ShouldGenerateValidJsonPlan()
{
    // Arrange
    var channel = GrpcChannel.ForAddress("http://localhost:5106");
    var client = new AgentService.AgentServiceClient(channel);

    var request = new AgentRequest
    {
        Intent = "Create a simple console app that prints 'Hello, PMCR-O!' in C#"
    };

    // Act
    var response = await client.ExecuteTaskAsync(request);

    // Assert
    Assert.True(response.Success);
    var plan = JsonSerializer.Deserialize<PlanOutput>(response.Content);
    Assert.NotNull(plan);
}

Expected Output:

JSON
{
  "plan": "Create a minimal C# console application using dotnet CLI",
  "steps": [
    {
      "action": "Create new console project: dotnet new console -n HelloPmcro",
      "rationale": "Minimal project structure for console output"
    },
    {
      "action": "Modify Program.cs to print message",
      "rationale": "Default template uses top-level statements, just add Console.WriteLine"
    },
    {
      "action": "Build and run: dotnet run",
      "rationale": "Verify the application works"
    }
  ],
  "estimated_complexity": "low"
}

What We Learned

Key Takeaways

  1. Native > Custom: Always prefer native LLM features over custom parsing
  2. Identity Matters: "I AM" prompts create better agent behavior
  3. Aspire Simplifies: Service discovery + observability out-of-the-box
  4. JSON Schema: Structured output is production-ready, not a hack

Next Steps

Immediate Actions

  • Run the code: Get the Planner working locally
  • Experiment with prompts: Try different complexity levels
  • Add logging: See what the LLM is actually doing

Reference Implementation

The complete PMCR-O framework codebase is available on GitHub. Star it. Fork it. Build your own self-referential agents.

View on GitHub →

Explore the PMCR-O Prompt Library for production-ready agent templates.

Happy building! 🚀