01

What is this thing?

You've probably chatted with an AI assistant. You type a question, it thinks, it answers. Simple, right? Under the hood there's a whole orchestration system making that work — and most people have no idea it exists.

🗼
The Air Traffic Control Tower

An agentic harness is the control tower for AI agents. It doesn't fly the planes. But nothing lands, takes off, or avoids crashing without it coordinating the whole operation.

💬

A Chatbot

User types a message. LLM predicts a reply. That's it.

🏗️

An Agent

Model + tools + skills + safety rails + a conversation loop. Can read files, call services, hand off to other agents.

🧰

The Harness

The invisible scaffolding that ties all of the above together. This repo is that scaffolding, built as a reusable template.

Why should you care? When your agent goes off the rails, knowing what the harness does helps you diagnose where things broke — not just that something did.

What happens when you send a message?

You hit Send. Your message takes a journey through five actors before a reply comes back. Step through the flow below.

👤
You
📡
AgUiRunHandler
🛡️
Pipeline
🧠
LLM
🔧
Tools
Click “Next Step” to begin the journey.
📡
AG-UI SSE · The chat front door

The React SPA POSTs each user message to POST /ag-ui/run; the server pins the response as Server-Sent Events and streams back AG-UI-protocol events until the run finishes. On this browser path the response is awaited fully, then sliced into 50-character TEXT_MESSAGE_CONTENT events so it still “types in.” The harness engine itself already streams real tokens as the model produces them — carrying those live tokens all the way out to the browser transport is the one remaining step.

SignalR · The side-channel

A parallel SignalR connection (/hubs/agent) carries lifecycle operations — starting a conversation, retrying a message, editing and resubmitting, changing settings — plus server-push notifications. AgentTelemetryHub.SendMessage exists in the codebase but the WebUI never invokes it; the chat send path was migrated to AG-UI.

🛡️
The MediatR pipeline

Every command passes through fifteen checkpoints — exception capture, content safety, tool permissions, governance, prompt-injection scan, audit, validation, tracing, timeout, response sanitization — before (and after) a handler touches it.

🧠
The LLM · Azure OpenAI

The brain. Reads the conversation, writes the reply, and asks to use tools when it needs real-world data.

🔧
The Tool System

Real code that runs when the LLM asks — reading files, calling APIs, searching the web.

It's not just a chatbot

Anyone can glue a text box to an API call. A real harness earns its name by handling five hard problems that a raw API call ignores.

🔐

Sandboxed Tools

Tools run inside a sandbox. The file tool can only see allowed folders — it physically cannot read your password vault.

📚

Progressive Skills

Skills load in three tiers — a title card, a folder, a full filing cabinet — so only what's needed fills the model's attention.

👯

Multi-Agent

One agent can delegate to another: a planner spawns a coder, a coder calls a reviewer. Each has its own skills and tools.

📊

Context Budget

Every agent has a token budget. The harness trims old messages before the context window overflows.

🔍

Full Observability

Every turn, every tool call, every token is traced. When something breaks, you see exactly where.

The conversation loop, in plain English

Every one of those capabilities hangs off one short piece of code — the outer loop that runs a conversation turn by turn.

CODE
public async Task<ConversationResult> Handle(RunConversationCommand request, CancellationToken cancellationToken)
{
    _logger.LogInformation("Starting conversation with {AgentName}, {MessageCount} messages, max {MaxTurns} turns",
        request.AgentName, request.UserMessages.Count, request.MaxTurns);
 
    var sw = Stopwatch.StartNew();
    var turns = new List<TurnSummary>();
    var totalToolInvocations = 0;
    AgentTurnResult? lastResult = null;
 
    foreach (var (userMessage, index) in request.UserMessages.Select((m, i) => (m, i)))
    {
        if (index >= request.MaxTurns)
        {
            _logger.LogWarning("Max turns ({MaxTurns}) reached for {AgentName}", request.MaxTurns, request.AgentName);
            break;
        }
 
        var turnCommand = new ExecuteAgentTurnCommand
        {
            AgentName = request.AgentName,
            UserMessage = userMessage,
            ConversationHistory = lastResult?.UpdatedHistory ?? [],
            ConversationId = request.ConversationId,
            TurnNumber = index + 1
        };
 
        lastResult = await _mediator.Send(turnCommand, cancellationToken);
    }
PLAIN ENGLISH

This function handles one whole conversation from start to finish.

First, leave a log note saying which agent is running and how many messages we plan to process.

Start a stopwatch so we can report how long the conversation takes.

Prepare an empty list for the turn-by-turn summary and a counter for tool uses.

Loop through the user's messages one at a time, numbering them as we go.

If we've hit the max-turns safety limit, log a warning and stop — runaway agents are expensive.

Build an “execute one turn” command with this message and the history so far.

Send the command through CQRS / MediatR — all fifteen pipeline behaviors (exception capture, content safety, tool permissions, governance, prompt-injection, audit, validation, tracing, timeout, response sanitization, …) run automatically before the turn handler.

Remember this turn's result so the next turn has context, and loop back for the next message.

💡
Key Insight

The harness isn't one giant “do everything” function. It's a short outer loop that delegates each turn to a pipeline of small, focused steps. Engineers call this separation of concerns — and it's why the system stays understandable as it grows.

The tech stack at a glance

Five technologies do most of the heavy lifting. You don't need to know them inside-out — just recognize the names when they come up.

🔷
C# / .NET 10

The programming language and runtime. Microsoft's equivalent of Java — strongly typed, fast, and enterprise-friendly.

☁️
Microsoft.Agents.AI + Azure OpenAI

The agent framework and the cloud-hosted LLM it talks to. Microsoft's stack for building agent-flavored apps.

🔁
MediatR + FluentValidation

The request pipeline and the rule engine. Every command gets validated before a handler ever touches it.

🔗
MCP (Model Context Protocol)

The universal plug for external tools. This harness can both expose tools to other agents and consume tools from other MCP servers.

📊
OpenTelemetry + Prometheus + Grafana

The observability layer — traces, metrics, dashboards. How you see what the agent actually did.

Quick check: do you get it?

1. What does the harness do that a raw API call to a language model doesn't?

2. Why is sandboxed tool execution important?

3. Scenario: your agent keeps eating through tokens and your bill is climbing. Which component would you investigate first?

➡️
Up next: Module 2 — Meet the Cast

Now that you've seen the full journey, let's zoom in and meet each of these actors face to face.

02

Meet the Cast

In the last module, you watched a message travel through the whole system. Now let's zoom in and meet each of the characters who made that journey happen.

🎬
The Film Crew Metaphor

Think of this system like a film production. The Director assembles the team, the Script Supervisor knows what everyone should say, the Props Department provides physical tools, and the Producer keeps everyone on schedule.

Presentation Layer — The Stage

Where the audience (users) interact with the show.

W
WebUI
C
ConsoleUI
H
AG-UI + AgentTelemetryHub

Application Layer — The Producer

Orchestrates everything but doesn't do the physical work itself.

AF
AgentFactory
M
MediatR Pipeline
CS
ContentSafety
H
ExecuteAgentTurnHandler

Domain Layer — The Script

The blueprints and definitions that describe what everything is — no behavior, just shapes.

AM
AgentManifest
SD
SkillDefinition
TC
TelemetryConventions

Infrastructure Layer — The Props Department

The physical stuff — connections to LLMs, file systems, databases, and external services.

LLM
LLM Provider
MCP
MCP Server
FS
FileSystemService
ST
State Management
Click any component to learn what it does
💡
Why This Matters for You

Clean Architecture means each actor has exactly one job and doesn't peek at anyone else's work. When you tell an AI coding assistant "add a new tool," knowing which layer it belongs in is the difference between a smooth change and fighting the entire codebase.

How Actors Collaborate

When you ask the agent to read a file, a whole cast of characters springs into action. Watch them talk to each other in real time.

Notice how the LLM doesn't touch the file system directly. It asks for a tool, and the handler checks permissions before executing it. Every actor stays in its lane.

The Domain Layer: Blueprints

The Domain layer defines what things are without knowing how they work. Here are two of its most important blueprints.

AgentManifest — The Character Sheet

CODE

public class AgentManifest
{
    public string Id { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public string Instructions { get; set; } = string.Empty;
    public IList<SkillReference> Skills { get; set; } = new List<SkillReference>();
    public IList<string> AllowedTools { get; set; } = new List<string>();
    public IList<ToolDeclaration> ToolDeclarations { get; set; } = new List<ToolDeclaration>();
    public StateConfiguration? StateConfiguration { get; set; }
    public DecisionFramework? DecisionFramework { get; set; }
    // plus Version, Author, Domain, Category, Tags, file-system metadata
}
        
PLAIN ENGLISH

A class called AgentManifest that describes one agent.

Opening the definition...

A unique identifier for this agent.

A human-readable name shown in UIs and logs.

A short sentence describing what this agent does.

The instructions field is the agent's system prompt — everything the LLM should know about its role before talking to a user.

Skills this agent can use — loaded progressively from disk.

An allow-list of tool names the agent is permitted to invoke.

Full tool declarations — richer metadata for tools (parameters, descriptions) the agent can call.

Optional configuration for stateful agents — tracking goals or progress across turns.

Optional decision framework — rubric the agent uses when picking next actions.

Plus ten more fields covering versioning, authorship, categorization, and file-system metadata.

End of the blueprint.

SkillDefinition — Three Tiers of Detail

Skills use progressive disclosure across three levels: Level 1 (Index Card, ~50-100 tokens), Level 2 (Folder, ~5,000-token instructions body), and Level 3 (Filing Cabinet, on-demand resource files). The agent sees Level 1 in a menu, opens Level 2 only for the skill it picks, and never auto-loads Level 3 — it reads those files only via file-system tools.

CODE

public class SkillDefinition
{
    #region Level 1: Index Card (Metadata — Always Loaded)

    public string Id { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public string Category { get; set; } = string.Empty;
    public IList<string> Tags { get; set; } = new List<string>();

    #endregion
        
PLAIN ENGLISH

A class describing one skill an agent can use.

Opening the definition...

This section is the "index card" — lightweight metadata that's always loaded into memory.

 

A unique identifier so the system can find this skill quickly.

A human-readable name (e.g., "Code Review").

A short sentence explaining what this skill does.

Which group it belongs to (e.g., "Development", "Analysis").

Searchable keywords — like hashtags for the skill.

 

End of Level 1. Deeper levels with full instructions load only on demand.

The Application Layer: The Director

The Application layer is where the action happens. It reads the Domain blueprints, calls Infrastructure services, and orchestrates everything. The AgentFactory is the director who assembles the full cast.

CODE

public async Task<AIAgent> CreateAgentAsync(AgentExecutionContext agentContext, CancellationToken cancellationToken = default)
{
    var chatClient = await _chatClientFactory.GetChatClientAsync(clientType, deploymentOrAgentId, cancellationToken);

    var chatClientBuilder = chatClient.AsBuilder()
        .UseOpenTelemetry(configure: c => c.EnableSensitiveData = true)
        .UseFunctionInvocation(configure: c => { c.AllowConcurrentInvocation = true; c.MaximumIterationsPerRequest = 5; })
        .Use(inner => new Middleware.ObservabilityMiddleware(inner, …))
        .Use(inner => new Middleware.ToolDiagnosticsMiddleware(inner, …))
        .UseDistributedCache(_distributedCache);

    var middlewareEnabledChatClient = chatClientBuilder.Build();
    var agent = new ChatClientAgent(middlewareEnabledChatClient, agentOptions);

    _logger.LogInformation("Creating agent {AgentName} using {ClientType} with {Deployment}",
        agentContext.Name, clientType, deploymentOrAgentId);

    return agent.AsBuilder().UseOpenTelemetry(…).Build();
}
        
PLAIN ENGLISH

An asynchronous method called CreateAgentAsync. Takes an AgentExecutionContext — the resolved bundle of skill instructions, tools, model, temperature, and deployment.

Opening the method...

Ask the chat client factory for an IChatClient connected to the right LLM provider.

 

Wrap the raw chat client in a tower of middleware: tracing first, …

… OpenTelemetry exports detailed spans (with the option to include prompts/responses), …

… function-invocation middleware loops on tool calls up to 5 iterations, …

… observability middleware records turn-level telemetry, …

… tool-diagnostics middleware traces every tool call and result, …

… distributed cache lets identical requests skip the LLM entirely.

 

Build the final IChatClient with all that middleware baked in.

Wrap it in a ChatClientAgent — the agent object the rest of the system holds onto.

 

Write a structured log entry: agent name, client type, deployment.

 

 

Return the agent with one final OpenTelemetry wrapper so the outer span captures every call.

End of the method.

🔍
Why Application, Not Infrastructure?

The AgentFactory decides what to assemble and in what order. It uses interfaces to talk to Infrastructure, so it never knows whether the LLM is Azure OpenAI, Anthropic, or a local model. That swappability is the whole point of Clean Architecture.

Infrastructure & Presentation

Two outer layers remain. Infrastructure does the heavy lifting with real services. Presentation is the front door.

LLM Providers

Connects to Azure OpenAI, Anthropic, or local models. Swappable — change providers without touching the agent logic.

🔌

MCP Integration

Expose tools as an MCP server for other agents, and consume external MCP servers for more capabilities.

💾

State & Storage

Saves conversations, agent state, and tool results. Keeps the agent's memory intact across turns.

🎨

WebUI & ConsoleUI

Two ways in: a browser-based chat interface or a developer's terminal. Same agent, different doorways.

📡

AG-UI SSE + AgentTelemetryHub

Two transports. AG-UI Server-Sent Events at POST /ag-ui/run carry the chat sends and the streamed response. SignalR at /hubs/agent handles lifecycle (start, retry, edit, settings) and pushes telemetry events to the dashboard.

📈

Observability

Every request is traced end-to-end with OpenTelemetry. If something breaks, you can replay the entire journey.

Check Your Understanding

1. Which layer owns the AgentManifest?

2. You want to add support for a new LLM provider (say, Google Gemini). Which layer do you modify?

3. Why does the AgentFactory live in Application, not Infrastructure?

4. Match each actor to the layer it belongs in:

AgentManifest
MediatR Pipeline
FileSystemService
AgentTelemetryHub

Domain — Pure blueprints, no behavior

Drop here

Application — Orchestration and business rules

Drop here

Infrastructure — Real services and external connections

Drop here

Presentation — User-facing interfaces

Drop here

Now that you know who's who, let's watch them in action — follow a real conversation from the first message to the final answer.

03

The Conversation Loop

You know that moment when an AI assistant pauses, calls a tool, gets results, and then keeps going? That is not one step — it is an entire loop running behind the scenes, and every iteration goes through a gauntlet of checks.

Think of it like a chess game with a coach. The agent (the player) studies the board and picks a move. The coach (the harness) checks the move is legal. Then the opponent (reality, via a tool call) responds. The game continues until checkmate — or the clock runs out.

U
User
P
Pipeline
H
Handler
L
LLM
T
Tool System
Click “Next Step” to watch 3 turns of a real conversation
💡
The Loop Secret

The conversation is not one big API call. It is a loop — each turn goes through validation, safety, and the LLM, then checks: “Did the AI ask for a tool, or is it done?” Tool call means keep looping. No tool call means we are finished.

The Gauntlet: 15 Pipeline Behaviors

Before the handler ever touches your message, it must survive a gauntlet of fifteen checkpoints. These are called pipeline behaviors, registered across two layers: ten AI-specific behaviors (outermost) wrap five common behaviors (innermost). Together they run in this exact order every time.

1
UnhandledException

Outermost layer — catches any uncaught error, logs it, and converts it into a structured failure so the rest of the system never sees a raw stack trace.

2
AgentContextPropagation

Stamps the current agent identity (name, session, deployment) onto the ambient context so every downstream call — LLM, tool, log line — can tell who asked for it.

3
AuditTrail

Writes an immutable audit record — what command, when, by whom — so security and compliance teams can replay any action months later.

4
ContentSafety

Scans the message for harmful, toxic, or policy-violating content via Azure Content Safety. Blocks dangerous requests before they ever reach the AI.

5
ToolPermission

Verifies this agent is actually allowed to use the tools it might call. Three-phase model: deny gates, ask rules, allow rules.

6
GovernancePolicy NEW

Evaluates every tool call against YAML-defined policies via Microsoft AGT. Returns one of: Allow / Deny / Warn / RequireApproval (triggers escalation) / Log / RateLimit. The harness's enforcement of organization rules.

7
PromptInjection NEW

Scans the user message for prompt-injection patterns (instruction override attempts, role hijacking, exfiltration markers) before the LLM ever sees it.

8
Hook

Fires registered lifecycle hooks (pre-turn / post-turn) so plug-ins like usage capture, evaluation harnesses, or custom rate-limiters can observe each command.

9
RetrievalAudit

For commands that hit RAG or knowledge graphs, records which chunks and sources were retrieved so you can later answer "where did the agent's answer come from?"

10
ResponseSanitization NEW

Runs AFTER the handler returns. Scans the LLM's response for leaked secrets, suspicious URLs, and policy-violating output and either redacts or blocks the response before it streams back.

11
RequestValidation

FluentValidation checks: required fields present, formats valid, ranges sane. Catches malformed inputs before they reach the handler.

12
Authorization

Confirms the caller has permission to execute this specific command. Distinct from tool permissions — this is about who is allowed to ask, not what tools they can use.

13
Caching

Checks if this exact request was already answered recently. If so, returns the cached result instantly — no need to bother the LLM.

14
RequestTracing

Opens an OpenTelemetry span around the handler so you can see exactly how long each step takes. Essential for diagnosing slow responses.

15
Timeout

Final safety net — trips a configurable timer that aborts the operation if the handler runs too long. Keeps runaway commands from blocking the whole system.

Handler (The Actual Work)

Finally! The request reaches the handler, which calls the LLM, processes tool calls, and returns the result. Everything before this was just making sure we are safe to proceed.

This pattern is called CQRS with MediatR. Every request is a “command” that gets routed through behaviors before reaching a handler. The order matters — UnhandledException is outermost so it can catch errors from anything inside; Timeout is innermost so it only times the actual work, not the safety checks.

ExecuteAgentTurn — One Round

This is the single most important piece of code in the harness. It handles one turn — send messages to the LLM, check if it asked for tools, and report back whether we are done.

CODE

public async Task<AgentTurnResult> Handle(ExecuteAgentTurnCommand request, CancellationToken cancellationToken)
{
    var skillId = _agentRegistry.TryGet(request.AgentName)?.Skill ?? request.AgentName;

    var agent = await _agentCache.GetOrCreateAsync(
        request.ConversationId, skillId,
        new SkillAgentOptions {
            AdditionalContext = request.SystemPromptOverride,
            DeploymentName = request.DeploymentOverride,
            Temperature = request.Temperature
        }, cancellationToken);

    var messages = new List<ChatMessage>(request.ConversationHistory)
    {
        new(ChatRole.User, request.UserMessage)
    };

    _usageCapture.TakeSnapshot();
    LlmUsageCapture.Current = _usageCapture;

    var response = await agent.RunAsync(messages, cancellationToken: cancellationToken);

    var usage = _usageCapture.TakeSnapshot();
    var responseText = ExtractResponseText(response);

    return new AgentTurnResult {
        Success = true, Response = responseText,
        UpdatedHistory = [..messages, ..response.Messages],
        ToolsInvoked = usage.ToolsInvoked,
        InputTokens = usage.InputTokens, OutputTokens = usage.OutputTokens,
        CostUsd = usage.CostUsd, Model = usage.Model
    };
}
        
PLAIN ENGLISH

This function handles one turn. It receives the command and a way to cancel if needed.

 

Look up which skill ID this agent uses. If the registry has no record, fall back to using the agent name as the skill ID directly.

 

Ask the agent cache for an agent. On the first turn it calls the factory to build one; on later turns it returns the cached instance.

The cache is keyed by conversation ID, so each conversation gets its own dedicated agent.

Pass through any per-turn overrides the WebUI set in the settings panel:

 

 

 

 

Build the full message list: all prior turns plus the new user message.

 

 

 

Take a “before” snapshot of token usage so we can subtract later and report exactly how many tokens this turn cost.

Stash the capture on a thread-local so middleware can find it.

 

Run the agent. Tool calls happen invisibly inside this call — the function-invocation middleware loops up to 5 times.

 

Take the “after” snapshot — the difference is what this turn cost.

Pull the final text out of the response object for streaming back.

 

Build the result record: success flag, response text, updated history, every tool that ran, token counts, dollar cost, and which model produced this answer.

 

 

 

 

 

RunConversation — The Full Loop

The previous screen showed one turn. This is the loop that chains turns together — keep going until the agent finishes or we hit the turn limit.

CODE

foreach (var (userMessage, index) in request.UserMessages.Select((m, i) => (m, i)))
{
    if (index >= request.MaxTurns)
    {
        _logger.LogWarning("Max turns ({MaxTurns}) reached", request.MaxTurns);
        break;
    }

    var turnCommand = new ExecuteAgentTurnCommand
    {
        AgentName = request.AgentName,
        UserMessage = userMessage,
        ConversationHistory = lastResult?.UpdatedHistory ?? [],
        TurnNumber = index + 1,
        ObservabilitySessionId = dbSessionId
    };

    lastResult = await _mediator.Send(turnCommand, cancellationToken);

    if (!lastResult.Success) break;
}
        
PLAIN ENGLISH

Walk through the caller's pre-supplied list of user messages, numbering them as we go.

 

If we have already used up MaxTurns, log a warning and stop — the safety cap.

 

 

 

 

Build the per-turn ExecuteAgentTurnCommand record:

 

Same agent every turn.

This turn's user message.

Pass forward the conversation history accumulated by the previous turn.

Turn number for telemetry.

Same observability session ID so all turns are joined in the Sessions dashboard.

 

Send the command through MediatR. The 12 pipeline behaviors run, then the handler from the previous screen.

 

If the turn failed (safety block, error, etc.), stop the loop early. Normal completion is when the UserMessages list is exhausted.

 

💡
Three Ways the Loop Ends

The outer loop ends when (1) the UserMessages list is exhausted — normal completion, (2) a turn fails (Success = false) — early break, or (3) we hit MaxTurns — the safety cap. Inside each turn, the tool-call loop is handled by the Microsoft.Extensions.AI UseFunctionInvocation middleware (up to 5 inner iterations).

Going Multi-Agent: The Orchestrator

One agent running a conversation loop is powerful. But what if the task is too big for one agent? The orchestrator breaks it into subtasks, hands each one to a specialist sub-agent, runs them one after another, then synthesizes their results into a final answer.

CODE

// Phase 1: Build the orchestrator and ask it to plan
var orchestrator = await _agentFactory.CreateAgentFromSkillAsync(
    request.OrchestratorName,
    new SkillAgentOptions { AdditionalContext = BuildAgentCatalogPrompt(request.AvailableAgents) },
    cancellationToken);

var planResponse = await orchestrator.RunAsync(planMessages, cancellationToken: cancellationToken);
var subtasks = ParseSubtasks(ExtractResponseText(planResponse), request.AvailableAgents);

// Phase 2: Run sub-agents sequentially, sharing one budget
foreach (var (agentName, subtask) in subtasks)
{
    await using var scope = _scopeFactory.CreateAsyncScope();
    var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();

    var conversationResult = await mediator.Send(new RunConversationCommand
    {
        AgentName = agentName, UserMessages = [subtask],
        MaxTurns = Math.Min(5, request.MaxTotalTurns - totalTurns)
    }, cancellationToken);

    subAgentResults.Add(new SubAgentResult(agentName, subtask, conversationResult.Response));
}

// Phase 3: Synthesize — orchestrator reads all sub-agent answers and writes the final response
var synthesis = await orchestrator.RunAsync(BuildSynthesisMessages(subAgentResults), cancellationToken: cancellationToken);
        
PLAIN ENGLISH

Phase 1: Build the orchestrator agent. Pass an “agent catalog” into its additional context so it knows which specialists are available.

 

 

 

 

Ask the orchestrator to produce a plan. Parse its response into a list of (agent name, subtask) pairs.

 

 

Phase 2: Run each sub-agent sequentially, not in parallel.

For each subtask, create a fresh DI scope.

Resolve a scoped MediatR instance so the pipeline behaviors get clean per-sub-agent state.

 

Dispatch RunConversationCommand for the sub-agent, capping at 5 turns and respecting the overall budget.

 

 

 

Collect the sub-agent's answer.

 

Phase 3: Hand all the sub-agent answers back to the orchestrator. It reads them and writes the user's final response.

 

Test Your Understanding

1. What determines whether the outer conversation loop (in RunConversationCommandHandler) keeps going or stops?

2. Scenario: Your agent makes 15 tool calls but never finishes answering. What config would you check first?

3. Which pipeline behavior runs outermost, and why does the order matter?

4. What is the key difference between RunConversation and RunOrchestratedTask?

The agent knows what to do because of its skills. Next, we will explore how skills work — and the clever trick that keeps the agent from drowning in information.

04

Skills — What Agents Know

An LLM has a finite memory called a context window. Dump everything into it and the agent drowns. Load too little and it doesn't know how to help.

Think of the context window as a glass jar. Every piece of information the agent needs is a colored section that takes up space. When the jar overflows, older information gets pushed out and the agent forgets.

Context Window — 128k tokens
System Prompt ~2,000
Skills ~5,000
Tool Schemas ~3,000
Conversation grows over time
Remaining space...

The ContextBudgetTracker is the person watching the jar. It tracks how many tokens each section uses and raises a warning when the jar is almost full.

💡
Key Insight

Progressive disclosure is one of the most powerful ideas in software — and in life. A good GPS doesn't show you the entire route at once; it gives you the next turn. The skills system works the same way — give the agent a table of contents first, and only load the full chapter when it's needed.

The Spy Briefcase — Three Compartments

Imagine a spy's briefcase with three compartments. Each one holds a different level of detail about a mission. You never open Compartment 3 during planning — that would overwhelm you.

1

Index Card

The label on the outside. Name, description, and tags — just enough to know what this skill is about. Always loaded. ~100 tokens.

2

Folder

The full mission briefing. Detailed instructions and which tools to use — loaded only when the agent activates this skill. ~5,000 tokens.

3

Filing Cabinet

Reference materials, templates, and scripts. Never loaded into the context window — the agent reads these from disk only when actively working. Unbounded size.

In the code, these three compartments are defined in a single class called SkillDefinition. Each tier is marked with a region comment so developers know which level it belongs to.

CODE

public class SkillDefinition
{
    #region Level 1: Index Card (Metadata — Always Loaded)
    public string Id { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    #endregion

    #region Level 2: Folder (Instructions — On Demand)
    public string? Instructions { get; set; }
    public string? Objectives { get; set; }
    public string? TraceFormat { get; set; }
    #endregion

    #region Categorization
    public string? Category { get; set; }
    public IList<string> Tags { get; set; } = new List<string>();
    public string? SkillType { get; set; }
    #endregion

    #region Runtime Configuration
    public IList<string> AllowedTools { get; set; } = new List<string>();
    #endregion

    #region Level 3: Filing Cabinet (Resources — On Demand)
    public IList<SkillResource> Templates { get; set; } = new List<SkillResource>();
    public IList<SkillResource> References { get; set; } = new List<SkillResource>();
    public IList<SkillResource> Scripts { get; set; } = new List<SkillResource>();
    public IList<SkillResource> Assets { get; set; } = new List<SkillResource>();
    #endregion
    // Plus Extensibility (ToolDeclarations), Hierarchy, Loading State, Computed Properties regions
}
        
PLAIN ENGLISH

Define a blueprint called "SkillDefinition"...

 

Compartment 1 — the label on the briefcase.

A unique identifier for this skill.

A human-readable name (e.g., "orchestrator-agent").

A short sentence explaining what this skill does — what shows up in the menu.

 

Compartment 2 — the full mission briefing.

The detailed instructions the agent follows when this skill is active.

Optional objectives section — what success looks like.

Optional trace format — how the agent should report its work.

 

Categorization — separate region.

Which group the skill belongs to (e.g., "orchestration").

Searchable hashtag-style keywords.

Type discriminator (e.g., "orchestration", "research").

 

Runtime config — separate region.

Allow-list of tool names the skill may invoke.

 

Compartment 3 — the filing cabinet in the back room.

Reusable document templates the agent can fill in.

Background reading material loaded from files on disk.

Automation scripts the agent can run when needed.

Arbitrary assets — images, configs, sample data.

Plus four more regions for tool declarations, hierarchy, loading state, and computed properties.

 

SKILL.md — A Skill's Mission File

Each skill is written as a Markdown file with a special header section. The header uses YAML frontmatter (the structured bit between the --- lines), and the body contains the actual instructions.

CODE

---
name: "orchestrator-agent"
description: "Coordinates specialized agents to accomplish complex, multi-step tasks."
category: "orchestration"
skill_type: "orchestration"
version: "1.0.0"
tags: ["orchestrator", "multi-agent", "coordination"]
allowed-tools: ["file_system", "web_search"]
---

# Your Role

You are a task orchestrator. When given a complex task:
1. Break it into independent subtasks
2. Assign each subtask to the most appropriate agent
3. Synthesize results into a coherent answer

## Task Decomposition Format
- Each subtask must have: description, assigned agent, expected output
- Prefer parallel subtasks over sequential when possible

## Guidelines
- Never execute subtasks yourself — always delegate
- If a subtask fails, retry with a different agent or approach
        
PLAIN ENGLISH

Start of the structured header (frontmatter).

Tier 1 data: The skill's name — matches the kebab-case folder it lives in.

Tier 1 data: The one-line summary that shows up in list_skills.

Tier 1 data: Which category this skill belongs to.

Tier 1 data: Type discriminator for routing.

Metadata: Version pin so callers can track changes.

Tier 1 data: Tags in YAML array syntax — the parser requires [...] form.

Runtime config: Tools this skill is allowed to invoke (also array syntax).

End of the structured header.

 

Tier 2 starts here — the full instructions for the agent.

 

The agent's job description in plain language.

Step 1: Decompose the problem.

Step 2: Hand off each piece to a specialist.

Step 3: Combine the results into one answer.

 

Section heading for output format rules.

Each sub-task needs a clear description, owner, and expected result.

Run tasks in parallel when they don't depend on each other.

 

Section heading for behavioral guidelines.

This agent is a manager — it coordinates, never does the work itself.

If something fails, don't give up — try a different approach.

The Budget Tracker — Loading the Right Compartment

Something has to decide which compartment of the briefcase to open, based on what the budget allows. We'll call that logic the TieredContextAssembler — a teaching name for the tier-loading decision. (In today's code the tiering is enforced by per-tier allocations recorded in the real ContextBudgetTracker and composed by the agent factory; there's no single class by this name. The Tier 1 / 2 / 3 idea below is exactly what happens.)

CODE

public sealed class TieredContextAssembler
{
    private const int DefaultTier1MaxTokens = 3000;
    private const int DefaultTier2MaxTokens = 8000;

    public Task<AssembledContext> AssembleContextAsync(
        SkillDefinition skill,
        string? basePath = null,
        CancellationToken cancellationToken = default)
    {
        var agentName = skill.Id;

        // Tier 1: org-level files declared in context_loading frontmatter
        var tier1 = LoadTier1(skill, agentName);   // truncates oversized files at budget boundary
        _budgetTracker.RecordAllocation(agentName, "tier1_context", tier1.TotalTokens);

        // Tier 2: domain/activity files — always loaded, also bounded by budget
        var tier2 = LoadTier2(skill, agentName);
        _budgetTracker.RecordAllocation(agentName, "tier2_context", tier2.TotalTokens);

        // Tier 3: NEVER preloaded — just return the lookup config
        var tier3 = BuildTier3Config(skill);  // returns Tier3AccessConfig with paths + fallback prompt

        return Task.FromResult(new AssembledContext(tier1, tier2, tier3, FormatPromptSection(tier1, tier2, tier3)));
    }
        
PLAIN ENGLISH

Define the Assembler — it builds context from skill tiers.

 

Tier 1 has a budget cap of 3,000 tokens.

Tier 2 has a budget cap of 8,000 tokens.

 

The main method — "assemble context for this skill."

Takes the skill, an optional base path for resolving relative files, …

… and a cancellation token.

 

Use the skill ID as the agent name for budget bookkeeping.

 

Always open Compartment 1.

Load Tier 1 files declared in the skill's context_loading config. Files that would blow the budget are truncated, not skipped.

Record the actual allocation under "tier1_context".

 

Always open Compartment 2 too.

Load Tier 2 files the same way. There is no “skip Tier 2” flag — if a skill declares Tier 2 files, they load.

Record the allocation under "tier2_context".

 

Compartment 3 never enters the jar.

Return a Tier3AccessConfig with the lookup paths and an optional fallback prompt — no file content.

 

Return the final AssembledContext bundling all three tiers plus the formatted prompt section ready to inject into the LLM's instructions.

Behind the Scenes: The Budget Negotiation

Here's what happens inside the system when the agent activates a skill. Three components "talk" to each other to decide what fits in the jar.

Compaction — When Memory Gets Full

When the budget tracker fires a warning, the system needs to make room. Compaction summarizes older messages into shorter versions — like replacing a 30-minute meeting recording with a half-page of notes.

1

Budget tracker detects tokens below threshold

2

A boundary message is created

3

Old messages are replaced with a summary

4

Freed tokens become available for new content

The CompactionBoundaryMessage records what happened — how many tokens were saved, what strategy was used, and a summary of the compacted content. This creates an audit trail so nothing is silently lost.

Before
18,500
tokens used
After
11,200
tokens used
7,300 tokens saved

Check Your Understanding

1. Why doesn't the agent load all skills at Tier 3 immediately?

2. Your agent keeps forgetting its instructions mid-conversation. What is most likely happening?

3. What triggers the compaction process?

4. Match each tier to its description:

Tier 1: Index Card
Tier 2: Folder
Tier 3: Filing Cabinet

Always loaded. Name, description, and tags. Costs ~100 tokens.

Drop here

Loaded on demand. Full instructions and tool declarations. Costs ~5,000 tokens.

Drop here

Never loaded into context. Templates and references read from disk. Unbounded size.

Drop here

Now you know what agents know. Next up: what they can actually do — and the safety rails that keep them from going too far.

05

How Tools Get to the Agent

Skills tell the agent what it knows. Tools are what it can actually do. Think of the harness like a Swiss Army knife with a safety lock — each blade is sharp and useful, but there is a mechanism that prevents you from opening the dangerous ones without explicit permission.

Watch each step below to see how a tool travels from a skill declaration all the way into the agent’s hands.

SK
Skill
FA
Factory
MC
MCP
DI
Keyed DI
AT
AITool
AG
Agent
Click “Next Step” to trace the tool resolution chain
💡
Why MCP First?

External tools from MCP servers can be updated, added, or removed without recompiling the harness. Checking MCP first means the newest capabilities always win.

🎨
Some tools hand back a picture, a form, or a chart — not just text

Most tools return words. But a few special tools let the agent reply with something you can actually look at or click: it can draw you a chart, lay out a table, show an image, or hand you a fill-in form right inside the chat instead of describing it in a paragraph. To the agent these are ordinary tools like any other — it “calls” them the same way — but what comes back is an interactive widget rather than a wall of text. Module 9 is devoted to this: the agent not only telling you things, but showing them.

The Tool Resolution Chain

Here is the real C# method that decides where each tool comes from. The pattern is: try MCP first, fall back to keyed DI, then try a fallback tool if one is declared.

CODE

private async Task<IEnumerable<AITool>?> ProvisionToolAsync(ToolDeclaration declaration)
{
    // Try MCP first
    if (_mcpToolProvider != null)
    {
        try
        {
            var mcpTools = await _mcpToolProvider.GetToolsAsync(declaration.Name);
            if (mcpTools?.Count > 0)
            {
                _logger.LogDebug("Resolved tool {ToolName} from MCP server", declaration.Name);
                return mcpTools;
            }
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "MCP resolution failed for {ToolName}, trying keyed DI", declaration.Name);
        }
    }

    // Fallback to keyed DI
    var resolved = ResolveToolByName(declaration.Name);
    if (resolved != null)
        return resolved;

    // Try fallback tool
    if (declaration.HasFallback && !declaration.FallbackIsManual)
    {
        resolved = ResolveToolByName(declaration.Fallback!);
        if (resolved != null)
        {
            _logger.LogInformation("Using fallback tool {Fallback} for {ToolName}",
                declaration.Fallback, declaration.Name);
            return resolved;
        }
    }

    if (!declaration.Optional)
        _logger.LogWarning("Required tool {ToolName} could not be resolved", declaration.Name);

    return null;
}
        
PLAIN ENGLISH

This method takes a tool declaration (a name + options) and tries to find a real, working tool for it.

 

First, check: is there an MCP server connected?

 

If yes, ask the MCP server: “Do you have a tool called this?”

Ask the server for tools matching this name and wait for the answer.

If the server returned at least one tool...

 

Log that we found it on MCP (for debugging later).

Hand back the MCP tools. Done — we found what we needed.

 

 

If the MCP call crashed, log the error but keep going — do not give up.

 

 

 

MCP did not have it. Try looking it up locally by its string key.

Found it locally? Great — hand it back.

 

 

Still nothing. If the declaration has a backup tool name, try that name instead.

 

Look up the backup tool locally.

Found the backup?

 

Log that we are using the backup so humans can see what happened.

Hand back the backup tool.

 

 

 

If this tool was required (not optional), log a warning — something is misconfigured.

 

Return nothing. The agent will not have this tool available.

The Sandbox — Keeping Tools on a Leash

Giving an AI agent unrestricted access to your file system is a terrible idea. The harness puts a sandbox around dangerous tools so they can only operate inside allowed base paths.

🚫

Deny Gates

Absolute blocks on Windows system folders — %WINDIR%, Program Files, System32. No override, no appeal.

Ask Rules

Operations that pause and ask the human for confirmation before proceeding. Like a popup that says “Are you sure?”

Allow Rules

Pre-approved operations inside safe directories. The agent can work freely within the fence.

CODE

public class ToolPermissionBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        // Runtime check — only requests that opt in get gated
        if (request is not IToolRequest toolRequest) return await next();

        // 3-phase resolution via ThreePhasePermissionResolver:
        // Phase 1: Deny gates — absolute blocks (%WINDIR%, Program Files, System32)
        // Phase 2: Ask rules — requires user confirmation
        // Phase 3: Allow rules — permitted operations
        var decision = await _resolver.ResolveAsync(toolRequest, ct);
        return decision.Allowed ? await next() : CreateRejection(decision);
    }
}
        
PLAIN ENGLISH

This is a permission gatekeeper that runs before every command.

It plugs into the pipeline so no command can skip it.

The constraint is just notnull — any request can pass through.

 

Inside Handle, the behavior receives the request and a delegate to invoke the next layer.

 

Runtime check: only requests that implement IToolRequest get gated. Everything else short-circuits to the next behavior.

 

Otherwise, run the three-phase resolver: deny gates → ask rules → allow rules.

 

 

 

Ask the resolver for a decision (allow / ask / deny).

If allowed, run the inner handler. Otherwise, return a structured rejection.

🔒
Defense in Depth

This 3-phase pattern — deny, then ask, then allow — is a classic security strategy called defense in depth. Even if one check has a bug, the others still protect you.

MCP — Plugging Into the World

MCP is the universal adapter for agent tools. Instead of hardcoding every capability, the agent can discover tools on remote servers at runtime — like plugging a USB device into your laptop and having it just work.

Watch the conversation below to see how an MCP client discovers and invokes a tool on a remote MCP server.

1
Discovery

The MCP client asks the server what tools it has — like browsing a menu before ordering.

2
Conversion

External tools are translated into the AITool format so the agent treats them identically to local tools.

3
Invocation

When the agent picks a tool, the MCP client forwards the call over HTTP and returns the result.

A2A — When Tools Are Not Enough

MCP lets an agent borrow tools from other servers. But what if the task is too complex for a single tool call? A2A goes further — instead of borrowing a tool, the agent delegates an entire task to another agent.

CODE

public record AgentCard

{
    public required string Name { get; init; }
    public required string Description { get; init; }
    public string? Url { get; init; }
    public IReadOnlyList<string> Capabilities { get; init; } = [];
    public IReadOnlyList<string> Skills { get; init; } = [];
    public string? Version { get; init; }
}
        
PLAIN ENGLISH

An AgentCard is a business card that an agent publishes so others can find it.

 

Every agent card must have a name — this is how other agents address it.

Required description — what this agent specializes in.

The URL where this agent can be reached over the network.

A list of high-level capabilities the agent advertises.

The list of named skills the agent exposes (matches its SKILL.md files).

Optional version pin so callers can target a specific build.

🔧

Keyed DI

Local tools baked into the harness. Fast, always available, but you must recompile to add new ones.

🔌

MCP

Borrow individual tools from external servers at runtime. Plug-and-play — no rebuild needed.

🤝

A2A

Delegate entire tasks to another specialized agent. Like hiring a subcontractor for a whole job.

Check Your Understanding

Why does the harness try MCP before keyed DI when resolving tools?

You add a new tool class to the harness but the agent cannot see it. What did you most likely forget?

What does “sandboxed” mean for the FileSystemService?

Match the right description: MCP = borrow remote tools, A2A = delegate entire tasks, Keyed DI = local tool lookup by name. Which statement is correct?

Tools give agents their hands. But how do you know if those hands are doing the right thing? Next: seeing inside the black box — and making sure nothing goes wrong.

06

Seeing Inside & Staying Safe

AI agents are black boxes by default. A conversation goes sideways and you have no idea why — was it the prompt? A bad tool result? A context overflow? The harness solves this by instrumenting everything with OpenTelemetry, so you can trace any conversation turn-by-turn, tool call by tool call.

Think of a hospital monitoring room. Every patient (agent conversation) has vital signs on a screen — heart rate is token usage, blood pressure is response latency, temperature is error rate. When something spikes, alarms fire.

The Three Pillars of Observability

Every production monitoring system rests on three pillars. The harness implements all three out of the box.

🔎

Traces (Jaeger)

Follow one conversation from start to finish. Every turn, every tool call, every LLM request becomes a span on a timeline — like a flight recorder for your agent.

📊

Metrics (Prometheus)

Aggregate numbers over time: average tokens per turn, tool call frequency, error rates. Metrics answer “how is the system doing overall?” rather than “what happened in this one conversation?”

📝

Logs (Structured JSON)

Detailed event-level records with structured fields. When a trace shows where something went wrong, logs tell you exactly what happened — the error message, the input that caused it, the stack of calls that led there.

Custom LLM Span Processor

The harness adds a custom span processor that enriches every trace with agentic context — agent name, turn index, tool calls, and token counts. This means when you open Jaeger, you see the agent’s perspective, not just raw HTTP calls.

CODE

public static class OrchestrationMetrics
{
    public static Histogram<double> ConversationDuration { get; } =
        AppInstrument.Meter.CreateHistogram<double>(
            OrchestrationConventions.ConversationDuration, "{ms}");

    public static Histogram<int> TurnsPerConversation { get; } =
        AppInstrument.Meter.CreateHistogram<int>(
            OrchestrationConventions.TurnsPerConversation, "{turn}");

    public static Counter<long> SubagentSpawns { get; } =
        AppInstrument.Meter.CreateCounter<long>(
            OrchestrationConventions.SubagentSpawns, "{spawn}");

    public static Counter<long> ToolCalls { get; } =
        AppInstrument.Meter.CreateCounter<long>(
            OrchestrationConventions.ToolCalls, "{call}");

    public static Histogram<double> TurnDuration { get; } =
        AppInstrument.Meter.CreateHistogram<double>(
            OrchestrationConventions.TurnDuration, "{ms}");

    public static Counter<long> TurnsTotal { get; } =
        AppInstrument.Meter.CreateCounter<long>(
            OrchestrationConventions.TurnsTotal, "{turn}");

    public static Counter<long> TurnErrors { get; } =
        AppInstrument.Meter.CreateCounter<long>(
            OrchestrationConventions.TurnErrors, "{turn}");
}
        
PLAIN ENGLISH

Create a class that holds all the measurement instruments for orchestration.

 

A histogram that records how long each conversation lasted, measured in milliseconds.

It pulls its name from a constants file so every metric uses consistent naming.

 

Another histogram tracking how many back-and-forth turns each conversation needed.

If a conversation takes 20 turns, that might signal a confused agent or unclear instructions.

 

A counter that increments every time the orchestrator spawns a sub-agent.

This lets you monitor how heavily the system relies on delegation.

 

A fourth counter that increments every time a tool call fires — aggregated per agent on the Tools dashboard.

 

Per-turn wall-clock duration histogram — lets you spot slow turns even if the overall conversation looks fine.

 

 

Counter of total turns executed across all agents.

 

 

Counter of turns that ended in error — divide by TurnsTotal to get error rate.

 

 

Content Safety: The Bouncer at the Door

Before any message reaches the agent, it passes through a content safety gate. Think of a nightclub bouncer — every person (message) gets checked at the door. If something looks wrong, they never get inside. The agent never even knows the message existed.

Watch both scenarios play out: a flagged message that gets blocked, then a clean message that sails through.

U
User
P
Pipeline
S
Safety
A
Agent
Click "Next Step" to see content safety in action

The Code Behind the Bouncer

ContentSafetyBehavior is a pipeline behavior — it sits in the MediatR pipeline and inspects every command that implements IContentScreenable.

CODE

public class ContentSafetyBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    public ContentSafetyBehavior(ITextContentSafetyService safety, IObservabilityStore observabilityStore)
    { _safety = safety; _observabilityStore = observabilityStore; }

    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        // Runtime opt-in: only requests that implement IContentScreenable get screened
        if (request is not IContentScreenable screenable) return await next();

        var content = screenable.ContentToScreen;
        ContentSafetyMetrics.Evaluations.Add(1);  // every screen, pass or fail

        if (!string.IsNullOrWhiteSpace(content))
        {
            var result = await _safety.ScreenContentAsync(content, ct);

            if (!result.IsSafe)
            {
                _logger.LogWarning("Content safety block: {Categories}", string.Join(", ", result.Categories));
                await _observabilityStore.RecordSafetyEventAsync(screenable, result, ct);
                ContentSafetyMetrics.Blocks.Add(1);

                // Prefer returning a structured Result.ContentBlocked rather than throwing
                if (ResultHelper.TryCreateFailure<TResponse>(result, out var failure)) return failure;
                throw new ContentSafetyException(result.Reason, result.Category);
            }
        }

        return await next();
    }
}
        
PLAIN ENGLISH

This is a safety checkpoint registered as a pipeline behavior.

It plugs into MediatR so it runs automatically — no manual wiring.

Constraint is just notnull; the screening opt-in happens at runtime, not by generic constraint.

 

Constructor takes the safety service and an observability store (to record safety events to Postgres).

 

 

The Handle method runs for every command.

 

Runtime opt-in: only screen if the request implements IContentScreenable. Otherwise pass through immediately.

 

Read the content directly from the property (not a method).

Bump the “evaluations” counter on every screen so you can see how often safety runs — pass or fail.

 

If there is actual content to check…

 

Send the content to the safety service and wait for the verdict.

 

If the verdict says the content is NOT safe…

 

Log a warning with the flagged categories.

Record the safety event to the observability store so Sessions dashboard can show it.

Bump the “blocks” counter — Prometheus tracks both evaluations and blocks separately.

 

Prefer returning a structured failure (Result.ContentBlocked) if the response type supports it. Cleaner than an exception.

Otherwise fall back to throwing a typed exception so the outer handler can map it.

 

 

 

If we got here, the content passed safety checks. Call next() to let the message continue.

 

 

The Permission System: Three Layers of Defense

Content safety catches harmful content. But what about harmful actions? An agent with access to a file deletion tool needs guardrails beyond “is this text safe?” The permission system controls what tools an agent is allowed to use, using a 3-phase resolution: Deny first, then Ask, then Allow.

1
Safety Gates (Deny)

Absolute blocks that cannot be bypassed. If a tool operation matches a safety gate, it is denied immediately — no exceptions, no overrides.

2
Ask Rules

Operations that require human confirmation before proceeding. The agent pauses and asks the user “Should I do this?” before taking action.

3
Allow Rules

Pre-approved operations that can proceed without interruption. Only checked after safety gates and ask rules have had their say.

The order matters. Deny rules always win. This is a security pattern called “default deny” — nothing gets through unless it is explicitly allowed, and certain things can never get through.

CODE

public sealed record SafetyGate(string PathPattern, string Description)
{
    public bool IsBypassImmune => true;
}

public sealed record ToolPermissionRule(
    string ToolPattern,
    string? OperationPattern,  // nullable — omit to match any operation
    PermissionBehaviorType Behavior,
    PermissionRuleSource Source,
    int Priority,
    bool IsBypassImmune = false);
        
PLAIN ENGLISH

A SafetyGate is a hard block on a specific file/path pattern. You describe what it blocks and why.

 

Bypass-immune is always true — no one and nothing can override a safety gate. Ever.

 

 

A ToolPermissionRule is a configurable rule for a specific tool. Positional record syntax — six parameters.

 

A tool name pattern (e.g., “file_system”, or “file_*” to match any file-related tool).

An optional pattern to match specific operations (e.g., “delete*” for delete operations only).

What should happen: Allow, Ask (prompt user), or Deny?

Where did this rule come from: the agent manifest, user settings, or a safety gate?

Higher priority rules win when two rules conflict for the same tool and operation.

Optional flag — if true, this rule cannot be overridden even by higher-priority rules. Same idea as a safety gate.

 

🔓
Why “Bypass-Immune” Matters

In most permission systems, an admin can override any rule. Safety gates break that pattern intentionally. Even if an agent’s configuration says “allow all file operations,” a safety gate on /etc/passwd still blocks it. This is defense-in-depth — layers of protection where no single layer’s failure compromises the whole system.

The Meta-Harness: An Agent That Optimizes Other Agents

💡
The Big Reveal

The meta-harness is an agent that optimizes other agents. Let that sink in.

Remember how skills (Module 4) are instruction files that shape agent behavior? The meta-harness reads the primary agent’s causal traces, finds patterns in what worked and what did not, and proposes improvements to those skill files automatically.

It is a four-phase optimization loop — like a coach reviewing game tape and updating the playbook.

1
Load Eval Tasks & Learnings

Read benchmark tasks from disk plus a learnings.md file that records what worked or failed on previous runs.

2
Proposer Generates a Candidate

A second agent reads the current best candidate, the run history, and prior learnings, then proposes a new candidate skill folder.

3
Evaluation Service Scores It

Run the candidate against benchmark tasks and compute a pass rate.

4
Better AND No Regressions

Accept only if the candidate beats the current best by the configured margin AND a regression suite check passes — otherwise discard. No silent regressions allowed.

CODE

// Phase 1: Load benchmark tasks and prior learnings from disk
var evalTasks = await LoadEvalTasksAsync(cfg.EvalTasksPath);
var priorLearnings = await LoadPriorLearningsAsync(cfg.LearningsPath);

// Phase 2: Build a proposer context, ask the proposer for a candidate
var proposerCtx = new HarnessProposerContext {
    CurrentCandidate = currentBest,
    OptimizationRunDirectoryPath = runDir,
    PriorCandidateIds = candidateHistory, Iteration = iteration,
    PriorLearnings = priorLearnings
};
var candidate = await _proposer.ProposeAsync(proposerCtx, cancellationToken);

// Phase 3: Evaluate the candidate against the eval task set
var evaluated = await _evaluationService.EvaluateAsync(candidate, evalTasks, cancellationToken);

// Phase 4: Accept only if better AND passes regression check
if (IsBetter(evaluated, currentBest, cfg.ScoreImprovementThreshold)
    && _regressionService.Check(regressionSuite, evaluated))
{
    await _candidateRepository.SaveAsync(evaluated, cancellationToken);
    _logger.LogInformation("New best candidate: pass rate {Rate} (was {Previous})",
        evaluated.PassRate, currentBest.PassRate);
}
        
PLAIN ENGLISH

Step 1: Load the benchmark task set and any prior “learnings” notes from disk.

 

 

Step 2: Build a context bundle for the proposer:

The current best skill candidate.

Where to write artifacts for this optimization run.

The IDs of every prior candidate & which iteration we are on.

Prior learnings so the proposer doesn't repeat past mistakes.

 

Ask the proposer to generate a new candidate.

 

Step 3: Run the candidate through the evaluation service against the benchmark tasks.

 

Step 4: Accept only if BOTH conditions hold: better than current best (with margin), AND a regression check passes — no new failures on the regression suite.

 

 

Save the new best to the candidate repository.

Log the new pass rate vs. the previous best.

 

 

The regression suite is the key safeguard here. The meta-harness never ships a change that makes the agent worse — it only keeps strict improvements. This is how autonomous agents can evolve without human babysitting.

The Full Picture

You have now seen every layer of the harness. A user message enters the system and passes through content safety, gets routed to an agent, which loads its skills, picks its tools, calls the LLM, executes tool calls, and returns a response — all while traces, metrics, and logs record every step. And above it all, the meta-harness watches, learns, and improves.

That is the Microsoft Agentic Harness. Not just an agent — a complete system for building, running, monitoring, and continuously improving AI agents.

Final Quiz: Test Your Understanding

Five questions covering concepts from across the course. These are scenario-based — there is no “scroll up and find the answer.” You need to think about what you learned.

Scenario: A user reports their agent conversation was painfully slow — each response took 30+ seconds. Which observability tool do you check first to find the bottleneck?

What happens when ContentSafetyBehavior flags a message as unsafe?

Why are safety gates “bypass-immune” while regular permission rules are not?

How does the meta-harness decide whether a proposed skill change is actually better?

Trace a message through all 6 modules — drag these pipeline steps into the correct order from first to last:

Agent loads skills into context
Response returned to user
ContentSafetyBehavior screens input
LLM generates response with tool calls
Orchestrator routes to correct agent
Tool execution engine runs requested tools

Step 1: First checkpoint

Drop here

Step 2: Message routing

Drop here

Step 3: Context preparation

Drop here

Step 4: AI reasoning

Drop here

Step 5: Action execution

Drop here

Step 6: Delivery

Drop here

You Made It.

You now understand the complete Microsoft Agentic Harness — from the Clean Architecture foundation, through the cast of components, the conversation loop, skills, tools, observability, content safety, permissions, and the self-improving meta-harness.

You are no longer a passenger. You can read the codebase, trace a message end-to-end, understand why architectural decisions were made, and have an informed conversation with any engineer on the team. That is not a small thing.

Now go explore the actual code. Open the solution in your editor. Find the files you learned about. Set a breakpoint in ContentSafetyBehavior and watch a message flow through it. The harness is yours to explore.

07

Welcome to the Tour Bus

The previous six modules gave you the map. This one is a guided ride — four real requests, traced through the actual files, line by line, with every stop labeled.

Until now we have talked about the harness — the cast, the pipeline, the skills system. From here on we follow real requests through real files. Each walkthrough is one route. At every stop we will tell you the exact file path, what is happening, and why it matters.

Think of it like riding the bus through a city you have only seen on a map. You already know that the courthouse is downtown and the library is on Main Street. Now you actually pass each building, the driver points it out, and suddenly the map makes sense.

A
Walkthrough A — A chat message, end-to-end

You type "Summarize my README" in the WebUI. We follow the message through 9 files until the response streams back.

B
Walkthrough B — Loading a skill

How a folder of markdown files becomes a tool the LLM can call — with token budgets enforced at every tier.

C
Walkthrough C — A tool call

The LLM asks for the file_system tool. We watch keyed dependency injection resolve it, the sandbox reject path traversal, and the result feed back into the next turn.

D
Walkthrough D — A RAG question

"What does the README say about RAG?" Eight services collaborate: classifier, transformer, dense + sparse retrievers, fusion, reranker, evaluator, assembler.

Before we leave the station — meet the cast

Each walkthrough involves a recurring cast of components talking to each other. Here is a quick refresher in their own voice.

💡
Read along, don't memorize

Every step in every walkthrough cites a real file path and line range. Open the repo in your editor. When a step says AgentTelemetryHub.cs:237, navigate there and read the actual code. The walkthroughs make the most sense when the file is open beside you.

Walkthrough A — "Summarize My README"

You type a sentence and hit Send. Nine files run before you see the first token of the response. Let's follow it.

The whole journey happens inside one roundtrip: browser POSTs one AG-UI run request, harness streams events back over Server-Sent Events until the run finishes. Tool calls inside that roundtrip do not become new roundtrips — they are looped invisibly by the framework middleware. By the time the SSE stream closes, all reasoning, all tool calls, and all evaluations are already done.

💡
The transport split — AG-UI vs SignalR

In ChatPanel.tsx:148 the WebUI calls agUiSend(...) for every user message — chat sends and the response stream both ride AG-UI SSE. SignalR is still wired (MapHub<AgentTelemetryHub>("/hubs/agent")) but the browser only uses it for lifecycle operations: StartConversation, RetryFromMessage, EditAndResubmit, SetConversationSettings. AgentTelemetryHub.SendMessage is fully implemented but the WebUI never invokes it — it’s dead code from the client’s perspective.

The nine stops

1
Browser POSTs to /ag-ui/run

Presentation.WebUI/src/hooks/useAgentStream.ts + Presentation.AgentHub/AgUi/AgUiEndpoints.cs:14

The WebUI’s useAgentStream hook calls createAuthenticatedAgUiAgent(...).run(...), which under the hood is an HTTP POST to /ag-ui/run with the conversation ID and message. The server pins the response as Server-Sent Events (Content-Type: text/event-stream) and routes to AgUiRunHandler.

2
AgUiRunHandler validates, locks, dispatches

Presentation.AgentHub/AgUi/AgUiRunHandler.csHandleRunAsync

The handler does the same ownership/lock/persist work the old SendMessage path did: validates the conversation belongs to the caller, acquires a per-conversation semaphore, appends the user message to the conversation store, then builds and dispatches the ExecuteAgentTurnCommand. Comment on the class explicitly says: “mirrors the logic in AgentTelemetryHub.DispatchTurnAsync but targets SSE.”

3
The MediatR pipeline — twelve checkpoints

Application.AI.Common/DependencyInjection.cs:59–68 + Application.Common/DependencyInjection.cs:63–67

Every command is wrapped, outer to inner, in 15 behaviors: UnhandledExceptionAgentContextPropagationAuditTrailContentSafetyToolPermissionGovernancePolicyPromptInjectionHookRetrievalAuditResponseSanitizationRequestValidationAuthorizationCachingRequestTracingTimeoutHandler. Three of these (Governance, PromptInjection, ResponseSanitization) were added in Phase 2 of the harness work and now form the security spine alongside ContentSafety.

4
The handler resolves the skill, asks the agent cache

Application.Core/CQRS/Agents/ExecuteAgentTurn/ExecuteAgentTurnCommandHandler.cs:42–96

The handler asks IAgentRegistry for the agent record by name — falling back to the agent name as the skill ID if no record exists. It then asks the IAgentConversationCache for an agent for this conversation: on the first turn the cache builds one via the factory; on later turns it returns the cached instance. Same agent, same conversation.

5
On a cache miss: AgentFactory builds the agent

Application.AI.Common/Factories/AgentFactory.cs:199–212 + 71–147

Only fires on the first turn of a conversation (or after the cache evicts). The factory looks up the SkillDefinition, maps it to an AgentExecutionContext (instructions, tools, temperature, deployment), then asks ChatClientFactory for an IChatClient and wraps it in a middleware tower: UseOpenTelemetry, UseFunctionInvocation (the inner tool-call loop), ObservabilityMiddleware, ToolDiagnosticsMiddleware, UseDistributedCache. The fully-wrapped client becomes a ChatClientAgent.

6
ChatClientFactory picks the model

Infrastructure.AI/Factories/ChatClientFactory.cs:116–131

A C# switch on AIAgentFrameworkClientType from AppConfigAzureOpenAI, OpenAI, AzureAIInference, PersistentAgents, or Anthropic. For Claude via Azure Foundry the factory returns an AnthropicClient wrapped with an AzureFoundryRewritingHandler that rewrites api.anthropic.com URLs to your Foundry endpoint.

7
The agent runs — tools handled invisibly

ExecuteAgentTurnCommandHandler.cs:79–99 (calls agent.RunAsync)

The handler arms _usageCapture to record token counts, then calls agent.RunAsync(messages, ct). Inside, the UseFunctionInvocation middleware loops: if the LLM emits a FunctionCallContent, the middleware finds the matching AIFunction, runs it, appends a FunctionResultContent to the messages, and calls the LLM again — up to MaximumIterationsPerRequest = 5 times.

8
Tool diagnostics trace the result

Application.AI.Common/Middleware/ToolDiagnosticsMiddleware.cs:79–113

On every call, this middleware scans messages for FunctionResultContent objects, redacts secrets, and writes an ExecutionTraceRecord with Type = ToolResult to the trace writer. This is what powers the Sessions drill-down dashboard from Module 6.

9
The response streams back over SSE

Presentation.AgentHub/AgUi/AgUiRunHandler.cs:261–270 (TEXT_MESSAGE_CONTENT emission) + AgUiEventWriter

Once _mediator.Send returns the full AgentTurnResult, the AG-UI handler chops the response into 50-character chunks and emits AG-UI-protocol TEXT_MESSAGE_CONTENT delta events, followed by a RUN_FINISHED event. Worth being precise here: the harness itself does stream real tokens on its turn path — the engine hands out each word as the model produces it. What you’re watching in this browser chat is a different, older transport: the AG-UI handler waits for the whole answer, then re-chops it into evenly sized chunks so it still “types in.” So the capability is real inside the harness; carrying those live tokens all the way through to the browser is the one remaining step.

+
Side-channel: SignalR for lifecycle

Presentation.WebUI/src/hooks/useAgentHub.tsx + Presentation.AgentHub/Hubs/AgentTelemetryHub.cs

Throughout this same turn, a parallel SignalR connection (/hubs/agent) is open. The WebUI uses it to invoke StartConversation (on first message), RetryFromMessage (retry button), EditAndResubmit (edit button), and SetConversationSettings — everything other than the message send itself. The hub also pushes HistoryTruncated and Error events to the browser as side-channel notifications.

The heart of stop 4 — in code

Below is the actual handler that fired at stop 4. It is the single most important method in the WebUI request path: build the agent, run it, capture token usage, return the result.

CODE

public async Task<AgentTurnResult> Handle(ExecuteAgentTurnCommand request, CancellationToken cancellationToken)
{
    var skillId = _agentRegistry.TryGet(request.AgentName)?.Skill ?? request.AgentName;

    var agent = await _agentCache.GetOrCreateAsync(
        request.ConversationId, skillId,
        new SkillAgentOptions { AdditionalContext = request.SystemPromptOverride,
            DeploymentName = request.DeploymentOverride, Temperature = request.Temperature },
        cancellationToken);

    var messages = new List<ChatMessage>(request.ConversationHistory)
        { new(ChatRole.User, request.UserMessage) };

    _usageCapture.TakeSnapshot();
    LlmUsageCapture.Current = _usageCapture;

    response = await agent.RunAsync(messages, cancellationToken: cancellationToken);

    var usage = _usageCapture.TakeSnapshot();
    var responseText = ExtractResponseText(response);
}
        
PLAIN ENGLISH

Handle one turn for the agent named in the request.

 

Look up which skill this agent uses. If the registry has no record, fall back to using the agent name as the skill ID directly.

 

Ask the agent cache for an agent for this conversation. Cache miss → build via factory; cache hit → return the cached instance. Passes through any per-turn overrides from the WebUI settings panel.

 

 

 

Build the message list: all prior turns plus the new user message. Conversation history is how the LLM remembers what it just said.

 

 

Take a "before" snapshot of token usage so we can subtract later and report exactly how many tokens this turn cost.

Stash the capture on a thread-local so the LLM middleware can find it without us threading it through every parameter.

 

Run the agent. This call is where the LLM gets invoked, tool calls happen, and the response (possibly after several invisible internal turns) comes back.

 

Take the "after" snapshot — the difference is what this turn cost in tokens.

Pull the final text out of the response object for streaming back to the browser.

 

Test your understanding

1. From the WebUI, which MediatR command does AgUiRunHandler dispatch for each user message?

2. The LLM asks for a tool, the tool runs, the LLM is asked again — where does this loop live?

3. The WebUI shows the response “typing in” chunk by chunk over SSE. Is this real token streaming?

Walkthrough B — Loading a Skill

A folder of markdown files becomes a tool the LLM can pick from a menu — with strict token budgets enforced at every level.

Skills are how the harness teaches the LLM new abilities without ballooning the prompt. The trick is progressive disclosure: three tiers of detail, only the cheapest tier loads up front.

Where skills live on disk

skills/ Root of all authored skills (configured in AppConfig.AI.Skills.AllPaths)
orchestrator-agent/ Folder name is the kebab-case skill ID
SKILL.md YAML frontmatter + markdown body = the entire skill
research-agent/ Another skill, same structure
harness-proposer/ A meta-skill that proposes changes to the harness itself

The eight stops

1
Registration at startup

Infrastructure.AI/DependencyInjection.cs:149–160

Two services are added to the DI container: ISkillMetadataRegistry as a singleton (one shared instance for the whole app) and FileSystemSkillContentProvider as the default ISkillContentProvider. The registry is lazy — it does not walk disk until someone asks for skills.

2
First request triggers the filesystem walk

Infrastructure.AI/Skills/SkillMetadataRegistry.cs:105–180 — method Discover

The registry reads AppConfig.AI.Skills.AllPaths, then for each root path recursively walks subdirectories up to 3 levels deep. When it finds a folder containing SKILL.md it stops recursing (a skill cannot nest another skill) and hands the file to the parser.

3
Parsing SKILL.md — the Index Card

Infrastructure.AI/Skills/SkillMetadataParser.cs:31–61

The parser slices out the YAML frontmatter between --- delimiters, parses key-value lines by hand (no YAML library), and builds a SkillDefinition record: Id, Name, Description, Category, Tags, AllowedTools, plus the markdown body as Instructions.

4
The three-tier SkillDefinition

Domain.AI/Skills/SkillDefinition.cs:26–316

Level 1 (Index Card) = Id, Name, Description — the ≈ 50-token summary. Level 2 (Folder) = Instructions, Objectives, TraceFormat — ≈ 5,000 tokens of behavior. Level 3 (Filing Cabinet) = lists of Templates, References, Scripts, Assets — loaded on demand via filesystem paths.

5
Skills exposed to the LLM as MCP tools

Infrastructure.AI.MCPServer/Tools/SkillTools.cs:13–108

list_skills returns only Level 1 fields (id, name, description, category, tags) — the menu. get_skill returns Level 1 plus the full instructions body (Level 2). This two-step pattern is progressive disclosure as seen from the LLM: pick by summary, then load full details only for the one you picked.

6
Context budget tracking

Application.AI.Common/Services/Context/ContextBudgetTracker.cs:91–108

A thread-safe ConcurrentDictionary keyed by agent name records how many tokens each component (tier1_context, tier2_context, etc.) has allocated. Before adding more, callers must invoke EnsureBudget; if the projected total would exceed the cap, ContextBudgetExceededException is thrown.

7
Tier 1/2 enforcement happens in the budget tracker

Application.AI.Common/Services/Context/ContextBudgetTracker.cs (configured via AppConfig.AI.Context)

The dedicated TieredContextAssembler class has been retired; tier-based file loading is now governed by per-tier budget allocations registered in ContextBudgetTracker and consumed at compose time by the agent factory's middleware. The Tier 1 / Tier 2 / Tier 3 model is unchanged conceptually — only the enforcement plumbing moved.

8
The agent receives instructions + tools

Application.AI.Common/Factories/AgentFactory.cs:127–135

The factory passes the skill's Instructions as ChatOptions.Instructions and the resolved AITool[] as ChatOptions.Tools. From this point the agent is ready: it knows who it is (instructions), what it can do (tools), and how to remember (conversation history).

The parser, in code

The parser at stop 3 is small but does the heavy lifting. Notice how everything maps directly to fields on SkillDefinition — no fancy YAML library, just careful string slicing.

CODE

public SkillDefinition ParseFromFile(string skillFilePath, string sourcePath)
{
    var raw = File.ReadAllText(skillFilePath);
    var frontmatter = ExtractFrontmatter(raw);
    var body = ExtractBody(raw, frontmatter);

    var (objectives, traceFormat, instructions) = ExtractStructuredSections(body);

    return new SkillDefinition
    {
        Id = name,  Name = name,  Description = description,
        Instructions = instructions,
        Category = ParseString(frontmatter, "category"),
        Tags = ParseList(frontmatter, "tags"),
        AllowedTools = ParseList(frontmatter, "allowed-tools"),
        FilePath = skillFilePath, IsFullyLoaded = true
    };
}

private static string? ExtractFrontmatter(string raw)
{
    if (!raw.StartsWith("---", StringComparison.Ordinal)) return null;
    var end = raw.IndexOf("---", 3, StringComparison.Ordinal);
    return end < 0 ? null : raw[3..end];
}
        
PLAIN ENGLISH

Parse one SKILL.md file from disk into a structured object.

 

Read the entire file into memory.

Pull out the YAML block at the top.

The rest is the markdown body — the agent's actual instructions.

 

Split the body into three structured pieces: a "## Trace Format" section, a "## Objectives" section, and everything else as the main instructions.

 

Build the final skill record.

 

ID and Name come from frontmatter. Description is the one-line summary the LLM sees in list_skills.

Instructions are the agent's full behavior brief — what the LLM gets when it calls get_skill.

Pull a single string for the category field.

Tags are a YAML list — same for allowed tools.

 

Remember where this skill came from on disk, mark it fully loaded.

 

 

 

Helper: pull the frontmatter block out of the raw file.

 

If the file does not start with ---, there is no frontmatter.

Otherwise find the closing --- and return everything between.

Use a range expression to slice the string — cheap, no allocations.

 

💡
Honest about the implementation

The parser sets IsFullyLoaded = true immediately — meaning the in-memory registry actually holds the Level 2 instructions from startup. Progressive disclosure is enforced at the API surface (list_skills hides instructions, get_skill reveals them) and at file resource access via the budget tracker. The Level 3 resources are never preloaded.

Test your understanding

1. The LLM gets a "menu" of skills. Which mechanism keeps the menu small?

2. Can one skill folder contain another skill folder inside it?

3. An agent tries to load too many Tier 1 context files. What happens?

Walkthrough C — The LLM Calls file_system

The LLM says "I need to read README.md." Eight stops later, the file content is back in the conversation history. Most of the stops exist to keep the LLM from escaping the sandbox.

This walkthrough is the most "computer-sciencey" of the four because tools rely on keyed dependency injection and reflection-based schema generation. But the path is short and the sandbox is the prize.

The eight stops

1
The ITool contract

Application.AI.Common/Interfaces/Tools/ITool.cs:39–73

Every tool implements one interface: a Name (matches the DI key), a Description, a list of SupportedOperations, and an ExecuteAsync(operation, parameters, ct) method. Tools are framework-independent — they know nothing about the LLM or MediatR.

2
Keyed DI registration at startup

Infrastructure.AI/DependencyInjection.cs:113–115

One line: services.AddKeyedSingleton<ITool>("file_system", (sp, _) => new FileSystemTool(sp.GetRequiredService<IFileSystemService>())). The key "file_system" is the same string the LLM will use to call this tool — that string is the only coupling between the agent declaration and the implementation.

3
Skill declares the tool, factory resolves it

Application.AI.Common/Factories/AgentExecutionContextFactory.cs:323–337

When a skill's frontmatter lists tools: [{name: "file_system"}], BuildToolsAsync calls ResolveToolByName("file_system"). That method does _serviceProvider.GetKeyedService<ITool>("file_system") and hands the result to the converter.

4
Conversion to an AIFunction with JSON schema

Application.AI.Common/Services/Tools/AIToolConverter.cs:52–99

AIFunctionFactory.Create takes a strongly-typed lambda (operation: string, parametersJson: JsonElement?) and auto-generates the JSON Schema the LLM will see. The lambda body calls tool.ExecuteAsync with the parsed parameters. Operation names are embedded in the function description as an enum so the LLM picks valid operations.

5
Tool sits in ChatOptions.Tools

Application.AI.Common/Factories/AgentFactory.cs:127–135

The converted AITool[] is attached to ChatOptions.Tools when the ChatClientAgent is built. On every LLM call the framework sends the tool schemas alongside the messages so the model knows what is available.

6
LLM emits a tool call; middleware dispatches

AgentFactory.cs:100–115 (the .UseFunctionInvocation middleware)

The model returns FunctionCallContent { Name = "file_system", Arguments = {"operation":"read","parameters":{"path":"README.md"}} }. Microsoft.Extensions.AI's UseFunctionInvocation middleware finds the matching AIFunction by name and invokes it — that lambda from stop 4 runs.

7
The sandbox check (defense in depth)

Infrastructure.AI/Tools/FileSystemService.cs:258–283 — method ResolveAndValidate

Five layers fire before any file IO: (a) SecureInputValidatorHelper rejects traversal sequences and null bytes; (b) GetFullPath resolves to an absolute path; (c) ResolveSymlinks chases symlinks so a link inside the sandbox cannot point outside it; (d) IsPathAllowed verifies the resolved path begins with an AllowedBasePath; (e) for writes, ValidateWriteTarget additionally blocks Windows/, ProgramFiles/, System/.

8
Result feeds back to the LLM

Application.AI.Common/Middleware/ToolDiagnosticsMiddleware.cs:79–113

FileSystemTool.ReadAsync returns ToolResult.Ok(fileContent). The function-invocation middleware appends a FunctionResultContent message to the conversation and re-invokes the LLM. ToolDiagnosticsMiddleware sees the result, redacts secrets, and writes an ExecutionTraceRecord to the trace store. The LLM now has the file contents and produces the final answer.

The sandbox check, in code

The single most security-critical method in the entire harness lives at stop 7. Every read, write, list, and search calls it first. The order of operations matters — resolve symlinks before validating the prefix.

CODE

private string ResolveAndValidate(string path, bool write = false)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(path);

    // Defense-in-depth: reject traversal patterns, null bytes, shell injection
    if (!SecureInputValidatorHelper.ValidateFilePath(path))
        throw new ArgumentException("Path contains invalid characters or traversal patterns.");

    var fullPath = Path.IsPathRooted(path)
        ? Path.GetFullPath(path)
        : ResolveRelative(path);

    // Resolve symlinks/junctions to real target, then re-validate
    fullPath = ResolveSymlinks(fullPath);

    if (!IsPathAllowed(fullPath))
    {
        _logger.LogWarning("Blocked access to path outside sandbox: {Path}", fullPath);
        throw new UnauthorizedAccessException("Path is outside the allowed sandbox.");
    }

    if (write) ValidateWriteTarget(fullPath);
    return fullPath;
}
        
PLAIN ENGLISH

Take a path string from the LLM. Return a real, fully-resolved, sandbox-checked path — or throw.

 

Cheap first check: reject empty or whitespace-only paths.

 

Run a pattern-based validator that catches .. sequences, null bytes, and shell metacharacters before they ever touch the filesystem.

 

If the validator rejects the path, throw immediately. No second chances.

 

If the LLM gave us an absolute path, normalize it. Otherwise resolve relative to the sandbox root.

 

 

Critical step: follow any symlinks to their real target. Without this, an attacker could plant a symlink inside the sandbox pointing to /etc/passwd and we would happily read it.

 

Now — after resolution — verify the path is rooted inside one of the configured allowed base paths.

If not, log a warning (security teams want to see this) and throw an UnauthorizedAccessException.

 

 

 

Extra check for writes: also block well-known dangerous targets like the Windows directory and Program Files even if they somehow ended up inside the sandbox.

Return the resolved, validated path. Now the caller can do file IO safely.

 

Where the order is the security

If IsPathAllowed ran before ResolveSymlinks, an attacker could put a symlink ./readme-shortcut inside the sandbox pointing to C:\Windows\System32. The prefix check would pass (it starts with the sandbox path) and the read would happen on the real target. Resolving symlinks first is non-negotiable.

Test your understanding

1. The skill says tools: [{name: "file_system"}]. What connects that string to the actual FileSystemTool class?

2. Why does ResolveAndValidate resolve symlinks before calling IsPathAllowed?

3. How many internal function-invocation iterations can the framework run per LLM call before stopping?

Walkthrough D — "What Does the README Say About RAG?"

Ten stops. Eight services. Two searches running in parallel. One math formula that does the magic at the end.

RAG is the harness's most layered subsystem. Most production RAG fails because someone wires up one piece (vector search) and calls it done. The harness layers eight pieces to recover from each one's weaknesses.

The flow visualized

O
Orchestrator
C
Classify/Route
D
Dense (Vector)
S
Sparse (BM25)
R
Rerank/CRAG
Click "Next Step" to follow the RAG pipeline

The ten stops

1
Entry: RagOrchestrator.SearchAsync

Infrastructure.AI.RAG/Orchestration/RagOrchestrator.cs:97–136

Single entry point for all RAG calls (agent tools, MediatR handlers, MCP). Opens an ActivitySource for tracing, reads the current RagConfig, decides the strategy, and routes.

2
Query classification

Infrastructure.AI.RAG/QueryTransform/LlmQueryClassifier.cs:37–60

A cheap LLM is sent a few-shot prompt and returns JSON: {"type": "SimpleLookup", "confidence": 0.85, ...}. "What does the README say about RAG?" classifies as SimpleLookup at high confidence — a literal fact-finding query.

3
Strategy routing

Infrastructure.AI.RAG/QueryTransform/QueryRouter.cs:68–105

A dictionary maps query types to retrieval strategies: SimpleLookupHybridVectorBm25, MultiHopMultiQueryFusion, GlobalThematicGraphRag. For our query, the router returns the original query unchanged (no transformer needed at high confidence).

4
Optional: HyDE / RAG Fusion transformation

RagFusionTransformer.cs:53–89 + HydeTransformer.cs:52–88

If confidence had been below 0.7, HyDE would have asked the LLM to write a 100-200-word hypothetical answer and embedded that instead. RAG Fusion would have generated 3-5 query variants. For our high-confidence query, neither runs.

5
Dense (vector) retrieval

Infrastructure.AI.RAG/Retrieval/AzureAISearchVectorStore.cs:85–126

EmbeddingService turns the query into a float vector. VectorizedQuery with KNearestNeighborsCount = topK is sent to Azure AI Search. Results come back with a DenseScore per chunk — cosine similarity in vector space.

6
Sparse (BM25) retrieval — in parallel

Infrastructure.AI.RAG/Retrieval/AzureAISearchBm25Store.cs:64–98

The raw query text is sent to the same Azure AI Search index but with QueryType.Simple — the engine runs BM25 keyword scoring. BM25 catches things vectors miss: exact phrases, code identifiers, rare terms. Scores are squashed to [0,1] via score / (1.0 + score).

7
Reciprocal Rank Fusion

Infrastructure.AI.RAG/Retrieval/HybridRetriever.cs:142–188

Both lists are joined via RRF: each chunk earns 1 / (k + rank) from each list (default k = 60). Chunks that appear in both lists get added scores and rise to the top. The math is dead simple, the effect is robust against retrieval-method bias.

8
Reranking

Infrastructure.AI.RAG/Retrieval/AzureSemanticReranker.cs:42–99

The fused candidate set is resubmitted to Azure AI Search with QueryType.Semantic. A cross-attention model scores each (query, chunk) pair more carefully than vector cosine ever could. Scores are normalized by dividing by 4.0 (Azure's semantic scale is 0–4).

9
CRAG evaluation

Infrastructure.AI.RAG/Evaluation/CragEvaluator.cs:52–91

A standard-tier LLM rates the retrieved set 0.0–1.0 and outputs JSON: Accept (≥ threshold), Refine (append a hint to the query and loop, max 2 retries), or Reject (return empty). This is the harness's auto-correction loop.

10
Assembly + citation tracking

Infrastructure.AI.RAG/Assembly/RagContextAssembler.cs:42–123 + CitationTracker.cs:13–51

Chunks are appended in rerank order until the 4,096-token budget is hit. For each chunk a CitationSpan records the exact character range in the assembled string. The final RagAssembledContext carries both the text and the citations, so the calling agent can render inline "click to verify" links back to original sources.

The RRF math, in code

Stop 7 is where the magic happens. The whole function is twenty lines and gives you provably-better retrieval than either method alone.

CODE

// chunkScores: Dictionary<string, (Chunk, DenseScore, SparseScore, FusedScore)>

for (var rank = 0; rank < denseResults.Count; rank++)
{
    var result = denseResults[rank];
    var rrfScore = 1.0 / (rrfK + rank + 1);
    chunkScores[result.Chunk.Id] = (result.Chunk, result.DenseScore, 0.0, rrfScore);
}

for (var rank = 0; rank < sparseResults.Count; rank++)
{
    var result = sparseResults[rank];
    var rrfScore = 1.0 / (rrfK + rank + 1);
    if (chunkScores.TryGetValue(result.Chunk.Id, out var existing))
        chunkScores[result.Chunk.Id] = (existing.Chunk, existing.DenseScore,
            result.SparseScore, existing.FusedScore + rrfScore);
    else
        chunkScores[result.Chunk.Id] = (result.Chunk, 0.0, result.SparseScore, rrfScore);
}

return chunkScores.Values
    .OrderByDescending(x => x.FusedScore)
    .Take(topK)
    .Select(x => new RetrievalResult(x.Chunk, x.DenseScore, x.SparseScore, x.FusedScore))
    .ToList();
        
PLAIN ENGLISH

Build a dictionary keyed by chunk ID. Each entry holds the chunk, its dense score, its sparse score, and its running fused score.

 

Walk the dense results from most relevant (rank 0) to least.

 

For each chunk, compute its RRF contribution: 1 / (60 + rank + 1). Top result gets 1/61. 50th result gets 1/111. Steep decay.

Store the chunk with its dense score and zero sparse score (for now).

 

 

Walk the sparse results the same way.

 

Compute the RRF contribution from this list.

 

If this chunk was also in the dense results: ADD this score to the running total. This is the bonus — appearing in both lists doubles your rank score.

 

 

Otherwise: this chunk is sparse-only, so its fused score is just this contribution.

 

 

Sort by the final fused score, descending.

Take the top K (default 10).

Build the final result objects with all three scores preserved for downstream observability.

Return the list.

 

💡
Why RRF works

Dense (vector) retrieval is good at meaning, bad at exact terms. Sparse (BM25) is the opposite. RRF says: don't pick one — reward chunks that show up in both. The 1/(k+rank) curve means top-ranked chunks dominate, but the constant k prevents rank 1 from being absurdly more valuable than rank 2. This single function is the load-bearing wall of modern hybrid retrieval.

Test your understanding

1. Dense and sparse retrieval — do they run sequentially or in parallel?

2. The CRAG evaluator scores the retrieved chunks at 0.55 (below Accept but above Reject). What happens?

3. A chunk is at rank 0 in the dense list and rank 4 in the sparse list. With k=60, what is its fused score (approximately)?

The Patterns You Keep Seeing

Four walkthroughs, dozens of files, but only a handful of patterns. Now that you have seen them in action, here is the synthesis.

Every codebase has its own dialect, but mature C# codebases tend to settle on the same handful of structural patterns. The harness uses five. Spot any of them in another project and you will know roughly where to look.

🔑

Keyed DI

String-keyed service registrations resolve by name at runtime. Tools, rerankers, vector stores, embedding services — all use this. The string is the contract between consumer and implementation.

Seen in: Walkthroughs A, B, C, D

🏭

Factory

AgentFactory, ChatClientFactory, AgentExecutionContextFactory — consistent Create*Async methods that hide construction complexity. Need an agent? Ask the factory; do not new one up.

Seen in: Walkthroughs A, B

🔐

Middleware Pipeline

Two stacks. MediatR behaviors wrap each command (12 stops). Microsoft.Extensions.AI middlewares wrap each LLM call (OpenTelemetry, function invocation, observability, tool diagnostics, distributed cache). Each layer can short-circuit, transform, or observe without the next layer knowing.

Seen in: Walkthroughs A, C

📒

Registry + Lazy Discovery

Singleton registries (SkillMetadataRegistry, AgentRegistry) walk disk on first access, cache forever, expose TryGet / GetAll. Same pattern, different content.

Seen in: Walkthrough B

📊

Trace + Tag, Then Move On

Every interesting operation opens an ActivitySource span, records a few key tags (agent name, turn number, token count), then proceeds. Observability is woven in at construction time, not retrofitted after a bug.

Seen in: Walkthroughs A, B, C, D

💡
Pattern recognition is the actual skill

You do not need to remember the names of every file you saw. You need to recognize the shape of these patterns when they show up elsewhere. The next time an AI tool generates code that looks like an unfamiliar mess, scan it for: a factory? A registry? A pipeline? Once you can name what you are looking at, you can steer it.

Final scenario quiz — combine what you've seen

1. Your agent is configured for Anthropic via Azure Foundry. Calls fail with "host not allowed: api.anthropic.com." Where do you look first?

2. You added a new tool. The LLM tries to call it and gets "tool not found." What three places must match exactly?

3. Scenario: RAG keeps returning chunks that are close to the question but never the actual answer chunks. The right docs are in the index. Which layer do you tune first?

4. You want to see exactly which files a single user's question hit through the whole harness — pipeline behaviors, RAG calls, tool calls. Where do you look?

End of the tour.

Four real requests. Roughly thirty-five files. Every one of them you can open right now in your editor and find the exact lines we cited. That is the harness, in operation.

When the next bug appears, you will not start from zero. You will know whether it is a pipeline issue, a factory issue, a retrieval issue, or a sandbox issue — and which file to point your AI tool at. That is the practical skill this course exists to build.

Open the repo. Pick one of the file paths from this module. Read the surrounding code. Set a breakpoint. Watch a request flow through. The walkthroughs ride is over; the codebase is yours to drive.

08

The Quality Loop

Five new systems shipped in the last work cycle. Individually they each fix one problem. Together they form a feedback loop that lets the harness watch itself, catch itself getting worse, and learn from corrections.

So far the course has shown a harness that runs agents well. This module is about a harness that improves agents over time — without you sitting there grading every output.

Think of an autoimmune system. White blood cells (governance) check every request. When something looks risky, they flag it for a doctor (escalation). Long-term blood panels (drift detection) catch slow degradation that no single test would notice. The body remembers what made it sick before (learnings). And the autonomic nervous system (autonomy tiers) decides how much you have to think about any of this consciously. The five systems below are the harness's version of that.

A
Autonomy Tiers

The trust dial. Each agent runs as Restricted, Supervised, or Autonomous. This setting decides how often everything below has to fire.

G
Governance

YAML-defined rules + Microsoft AGT policy engine. Evaluates every tool call. Six possible verdicts: Allow, Deny, Warn, RequireApproval, Log, RateLimit.

E
Escalation

When governance returns RequireApproval, this pauses the agent and asks a human via Server-Sent Events. Times out gracefully if no one answers.

D
Drift Detection

Uses EWMA (a smoothed running average) to detect when an agent's quality scores slide away from a stored baseline. Four severity tiers escalate automatically.

L
Learnings

Natural-language facts captured from corrections, drift alerts, and escalation outcomes. They decay over time so old wisdom doesn't poison new behavior.

Meet the loop — in their own voice

The five systems talk to each other on every interesting turn. Here is roughly what that conversation looks like when an agent does something governance doesn’t love.

💡
Why these five together

Any one of these systems on its own is just a feature. Together they are a loop: governance catches risky calls → escalation surfaces them to humans → the human verdict becomes a learning → drift detection notices when patterns shift → autonomy tiers decide how aggressively the whole loop fires for each agent. That’s the “quality loop”.

Autonomy Tiers — The Trust Dial

Every agent has one knob that governs how much freedom it gets. Three positions: Restricted, Supervised, Autonomous.

Without autonomy tiers, you have two bad options: every agent asks for permission on every action (annoying, slow) or every agent runs unsupervised (terrifying). The tier is the per-agent compromise — a new internal-tools agent might start Restricted, a production support agent might be Supervised with a handful of pre-approved tools, and a fully-vetted research agent can be Autonomous.

🔒

Restricted

Default behavior is Ask. Every action requires approval. Safety gates still apply as hard denies. Use for new agents, untrusted skills, demos.

👁

Supervised

Default is also Ask, but specific tools can be pre-Allowed via ToolOverrides in tier policy. The most useful tier in practice — high oversight, low friction on the safe tools.

🚀

Autonomous

Default is Allow. Safety gates and AGT policies are still the ceiling. Use for stable, well-instrumented agents whose drift scores have stayed clean for a long time.

In code it’s a single enum — deliberately small so it can be compared with >= for “requires at least tier X” checks elsewhere in the harness.

CODE

public enum AutonomyLevel
{
    /// Read-only tier. Default behavior is Ask, forcing approval for every action.
    /// Safety gates handle true Deny scenarios.
    Restricted = 0,

    /// Recommend-and-wait tier. Default is also Ask, but Supervised agents
    /// can have specific tool Allow overrides via ToolOverrides in tier policy.
    Supervised = 1,

    /// Act-within-guardrails tier. Default is Allow. Safety gates and AGT
    /// policies still apply as a ceiling above the tier's baseline.
    Autonomous = 2
}
        
PLAIN ENGLISH

Define the three trust levels an agent can run at.

 

Most cautious tier. Every tool call needs human approval.

Safety gates — hard organizational denies — still apply regardless of tier.

Value 0 (lowest trust).

 

Middle tier. Default is still "ask", but you can pre-allow specific tools (e.g. read-only file ops).

This is where most production agents live — oversight for the dangerous stuff, freedom for routine work.

Value 1.

 

Most trusted tier. Default is "allow" — the agent acts immediately.

Governance policies and safety gates can still block specific actions; the tier just sets the default.

Value 2 (highest trust).

 

💡
Why an enum, not a config string

Integer-backed enums let you write if (agent.Tier >= AutonomyLevel.Supervised) — concise, type-safe, and impossible to typo. The XML doc comments above each value are the source of truth for what the tier means; the rest of the codebase reads them, so changing the comment changes the docs everywhere.

🎯
The dial isn’t the whole story — every tool has a risk rating too

The tier sets the agent’s default trust. But not every action is equally dangerous. Reading a file is nearly harmless; deleting a folder or spending money is not. So each tool carries its own risk tier and a blast radius — a plain measure of “how much damage could this cause if it went wrong?” When the agent reaches for a tool, the harness combines the agent’s dial with the tool’s risk rating. A Supervised agent might sail through low-risk reads but still get stopped for a high-blast-radius delete. The same risk rating also decides how loudly to escalate when a human does need to be asked — a risky tool raises a louder alarm than a trivial one.

Governance — The Rulebook Engine

Six decisions, one pipeline behavior, a YAML policy file. Sits at position 7 of the pipeline — between tool permission and the hook layer.

Governance answers: “Should this specific tool call, by this specific agent, on these specific arguments, proceed?” The answer is one of six verdicts.

A
Allow

Proceed without intervention. The vast majority of calls land here when the agent is well-instructed.

D
Deny

Hard block. Returns Result.GovernanceBlocked with the reason and the matched rule. No appeal.

W
Warn

Allow the call but log it as concerning. Used for tools that aren’t banned but warrant a second look in audit review.

R
RequireApproval

Pause the turn and hand off to the Escalation system (next screen). The agent waits until a human votes or the timeout fires.

L
Log

Allow and quietly record. The lightest verdict — used when you want a paper trail without changing behavior.

T
RateLimit

Allow up to N calls per window, then reject the rest. Useful for expensive or rate-capped external APIs.

Inside GovernancePolicyBehavior.Handle

The pipeline behavior at position 7 of the gauntlet. Note the early bail-out if the request isn’t a tool call, and the fail-closed behavior when the engine isn’t available.

CODE

public async Task<TResponse> Handle(TRequest request,
    RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
    if (request is not IToolRequest toolRequest)
        return await next();

    if (!_config.CurrentValue.Enabled || !_policyEngine.HasPolicies)
        return await next();

    var agentId = _executionContext.AgentId ?? "unknown";

    var decision = _policyEngine.EvaluateToolCall(agentId, toolRequest.ToolName);

    if (_config.CurrentValue.EnableAudit)
        _auditService.Log(agentId, toolRequest.ToolName, decision.Action.ToString());

    if (decision.IsAllowed) return await next();

    if (decision.Action == GovernancePolicyAction.RequireApproval)
        return await HandleRequireApprovalAsync(agentId, toolRequest, decision, next, cancellationToken);

    if (ResultHelper.TryCreateFailure<TResponse>(nameof(Result.GovernanceBlocked),
            decision.Reason, out var blocked)) return blocked;

    throw new InvalidOperationException($"Governance policy denied: {decision.Reason}");
}
        
PLAIN ENGLISH

The behavior’s Handle method — runs for every command in the pipeline.

 

First gate: governance only cares about tool requests. Everything else passes through unchanged.

 

 

Second gate: if governance is disabled in config, or no policies are loaded, pass through.

 

 

Pull the agent ID from the ambient execution context (set earlier by AgentContextPropagationBehavior).

 

Ask the policy engine: given this agent, this tool, what should happen? Returns a GovernanceDecision.

 

If auditing is on, write a tamper-evident audit record — agent, tool, decision.

 

If the decision is Allow (or any "ok to proceed" verdict like Log/Warn), continue to the inner handler.

 

If RequireApproval, branch into the escalation flow — we’ll see this on the next screen.

 

Otherwise the decision was Deny (or RateLimit refused). Try to return a structured Result.GovernanceBlocked …

… if the response type supports it. Carries the reason string back to the caller.

 

If the response type isn’t a Result, throw — better to fail loudly than silently allow.

 

Quick check

What is the difference between ToolPermission and Governance?

Escalation — Pause and Ask a Human

When governance returns RequireApproval, the agent pauses. The UI receives a EscalationRequestedEvent over Server-Sent Events. A human votes. The agent resumes — or fails closed.

This is the single most concrete benefit of the whole governance stack: human-in-the-loop without the agent permanently blocking on every call. The cost of being safe is one extra SSE event when something risky happens.

The shape of an escalation event

This is what the browser actually receives when an agent triggers approval. It is a JSON object with a polymorphic type discriminator so multiple event types can travel the same stream.

CODE

public sealed record EscalationRequestedEvent : AgUiEvent
{
    [JsonPropertyName("escalationId")]
    public required string EscalationId { get; init; }

    [JsonPropertyName("agentId")]
    public required string AgentId { get; init; }

    [JsonPropertyName("toolName")]
    public required string ToolName { get; init; }

    [JsonPropertyName("description")]
    public required string Description { get; init; }

    [JsonPropertyName("priority")]
    public required string Priority { get; init; }

    [JsonPropertyName("approvers")]
    public required IReadOnlyList<string> Approvers { get; init; }

    [JsonPropertyName("timeoutSeconds")]
    public required int TimeoutSeconds { get; init; }

    [JsonPropertyName("arguments")]
    public IReadOnlyDictionary<string, string>? Arguments { get; init; }
}
        
PLAIN ENGLISH

An immutable record that gets serialized to JSON and pushed to the browser.

It inherits from AgUiEvent, which adds a polymorphic "type": "EscalationRequested" field automatically.

 

A unique ID for this escalation — the UI uses it to correlate the eventual resolution event.

 

 

Which agent triggered the escalation. Helps the human know what context they’re approving in.

 

 

The tool the agent wants to call (e.g. "file_system.write").

 

 

A human-readable summary the UI can show as the headline of the approval prompt.

 

 

Urgency level — "Informational" / "Blocking" / "Critical". Lets the UI color-code or route to different channels.

 

 

Ordered list of who can vote on this escalation.

 

 

How long the escalation is valid for. After this many seconds with no decision, the timeout-action config kicks in.

 

 

Optional — the actual arguments the agent wanted to pass to the tool, sanitized for display.

 

💡
Two wait modes

The escalation service supports both blocking (RequestEscalationAsync — the turn awaits the resolution) and non-blocking (QueueEscalationAsync — the turn proceeds optimistically; humans get an audit-only ping). Which mode runs is determined by the agent’s autonomy-tier EscalationWaitBehavior. Restricted agents always block; Autonomous agents can be configured to queue.

Quick check

An agent triggers an escalation while running from the CLI (no browser connected). What happens to the SSE event?

Drift Detection & Learnings — The Long Memory

Two systems that work as a pair: drift catches slow quality regressions; learnings remember the corrections so the same mistake isn’t repeated.

Drift Detection — the canary

After each evaluation run, quality scores (faithfulness, relevance, structural correctness, …) feed into an EWMA running average. Each new score updates the EWMA via the formula EWMAt = λ · xt + (1 - λ) · EWMAt-1. The current EWMA is compared to a stored baseline; if it has drifted by more than N sigma, an alert fires.

CODE

var lambda = config.EwmaLambda;
var previousEwma = existingState?.CurrentEwma ?? baselineMean;

var newEwma = lambda * currentValue + (1 - lambda) * previousEwma;
var deviation = sigma > 0
    ? Math.Abs(newEwma - baselineMean) / sigma
    : 0.0;

var updatedState = new EwmaState
{
    Scope = baseline.Scope,
    ScopeIdentifier = baseline.ScopeIdentifier,
    Dimension = dimension,
    CurrentEwma = newEwma,
    SampleCount = newSampleCount,
    LastUpdatedAt = _timeProvider.GetUtcNow()
};

var saveResult = await _stateStore.SaveStateAsync(updatedState, ct);
        
PLAIN ENGLISH

Read lambda from config — how reactive the smoothing should be (typical: 0.2).

Get the previous EWMA. If this is the first sample, start from the baseline mean.

 

The EWMA update step. New value contributes lambda; the old EWMA contributes the rest.

Compute how many sigma the new EWMA is away from the baseline. Sigma is the baseline’s standard deviation.

Special-case: if there’s no variance in the baseline, deviation is forced to 0 instead of NaN.

 

 

Build the new state record to persist.

 

Which agent / dataset / dimension this state belongs to.

 

 

The just-computed EWMA so the next call can pick up where we left off.

Bump the sample count.

Record when this state was last updated — used for staleness checks elsewhere.

 

Persist via the configured state store (graph or in-memory).

 

The resulting deviation is classified into four severity tiers by DriftSeverityClassifier:

None Below the warn threshold. Normal operation, no signal.
Warn Deviation past the warn threshold. Logged; no escalation yet.
Alert Notifier fires — ops team gets a Slack ping. Drift is real but not catastrophic.
Escalate Deviation past the escalate threshold. Pipes directly into the Escalation system — a human must intervene.

Learnings — remembering corrections

When something goes wrong — a drift alert, an escalation Deny, a human correction in chat — the harness can capture a learning. Learnings are natural-language facts: “agents writing to system paths are almost always hallucinating”, “customer X prefers concise summaries”. They decay over time so old wisdom doesn’t poison new behavior.

CODE

public Task<double> CalculateFreshnessAsync(LearningEntry learning, CancellationToken ct)
{
    if (learning.DecayClass == DecayClass.Permanent)
        return Task.FromResult(1.0);

    var shelfLifeDays = learning.DecayClass switch
    {
        DecayClass.Volatile => config.VolatileShelfLifeDays,   // default 7
        DecayClass.Stable => config.StableShelfLifeDays,       // default 180
        _ => config.StableShelfLifeDays
    };

    var referenceTime = learning.LastReinforcedAt ?? learning.CreatedAt;
    var ageDays = (_timeProvider.GetUtcNow() - referenceTime).TotalDays;
    var rawFreshness = Math.Clamp(1.0 - (ageDays / shelfLifeDays), 0.0, 1.0);

    if (config.BiasCorrection && learning.UpdateCount is > 0 and < 5)
    {
        var correctionFactor = 1.0 / (1.0 - Math.Pow(1.0 - config.DecayBiasAlpha, learning.UpdateCount));
        return Task.FromResult(Math.Clamp(rawFreshness * correctionFactor, 0.0, 1.0));
    }

    return Task.FromResult(rawFreshness);
}
        
PLAIN ENGLISH

Compute how "fresh" a learning is on a scale of 0 to 1 (1 = brand new, 0 = expired).

 

Permanent learnings (e.g. compliance policies, hard organizational facts) never decay.

 

 

For other decay classes, look up how long the learning is supposed to live.

Volatile = ~1 week (tactical, ephemeral knowledge).

Stable = ~6 months (durable patterns that change slowly).

Fall back to stable for unknown classes.

 

 

Use the last reinforcement time, or the creation time if never reinforced.

How many days has it been?

Linear decay: 100% on day 0, 0% at end of shelf life. Clamp to [0, 1].

 

Bias-correction guard: if a learning is young AND has been reinforced only a few times,

apply an EMA bias correction. This stops young, lightly-reinforced learnings from looking artificially weak.

Return the corrected score, clamped to [0, 1].

 

 

Otherwise return the raw linear-decay score.

 

💡
Why decay matters

Without decay, learnings accumulate forever and the agent gets weighed down by stale advice. With decay, the system implicitly believes: "this knowledge was true when we captured it; it’s probably less true now; if it’s still useful someone will reinforce it." A background service (LearningsPruningBackgroundService) periodically soft-deletes zero-freshness entries.

Quick check

Why use EWMA instead of a plain rolling average for drift detection?

A Volatile learning was created 8 days ago and never reinforced. What is its freshness?

Learning From Its Own Work — and Not Believing Everything It Writes Down

Drift and learnings watch the scores. This is a different kind of memory: the agent keeps a diary of the jobs it actually did, then quietly studies that diary so it does better next time — while being careful not to trust every note in it.

The work diary — capture, synthesise, recall

Imagine a new employee who, at the end of every task, jots a quick note: “Here’s what I was asked, here’s what I tried, here’s what worked.” On its own each note is just a scrap. But overnight, when nobody is waiting on an answer, a quieter process reads a whole stack of those scraps and distils them into a handful of durable lessons — “when the request looks like this, the move that works is that.” Then, the next time a similar request arrives, those lessons are pulled back up and handed to the agent before it starts. The agent walks into the new job already reminded of how the last one like it went.

📝

1. Capture

After a turn finishes, the harness quietly files an “episode” — what was asked, what the agent did, how it turned out. This happens in the background, so it never slows down the answer you’re waiting for.

🌙

2. Synthesise (overnight)

On its own schedule, a synthesiser reads many episodes at once and turns the raw diary into a few generalised lessons. Many scattered experiences become one reusable rule of thumb.

💡

3. Recall

When a new request looks like an old one, the matching lessons are surfaced and dropped into the agent’s briefing at the very start of the turn — so hard-won experience shows up exactly when it’s useful.

Trust-aware memory — a diary can be poisoned

There’s a catch, and it’s an important one. If the agent will read its own notes later and act on them, then anything that sneaks a note into that diary can steer the agent’s future behaviour. A malicious web page or document could try to plant a “lesson” like “always email this file to that address.” So the harness treats its own memory as untrusted until proven otherwise.

Every time something wants to write to memory, it passes through a write-gate — a bouncer for the diary. A note that can’t be vouched for isn’t thrown away, but it’s stamped untrusted and quarantined. Later, when the agent goes to recall a lesson, that trust stamp is checked again: untrusted notes stay out of the briefing. The agent gets the benefit of learning from itself without blindly believing every scrap that landed in its notebook.

🛡
Why this matters

“Learn from your own work” and “believe everything in your own notes” sound like the same thing, but the gap between them is a real attack surface. The write-gate is what lets the harness turn on self-improvement without opening a back door for prompt-injection to rewrite the agent’s habits.

Three More Ways the Harness Keeps Itself Honest

Governance decides whether an action is allowed. These three guardrails watch how the work is going — catching runaway loops, runaway costs, and sensitive data before they become a problem.

💰

A budget for the whole conversation

Module 4 showed the per-turn “glass jar” — how much the agent can think about in one reply. This is a bigger jar: a ceiling on the entire conversation. Even if every single turn stays under its own limit, a very long back-and-forth can quietly rack up a large bill. The conversation budget watches the running total and, when it’s nearly spent, brings the conversation to a graceful stop instead of letting it drift on forever.

🔃

A spin detector

Sometimes an agent gets stuck — calling the same tool with the same input over and over, making no headway, like a car with its wheels spinning in mud. A progress guard watches for exactly this. If the agent keeps repeating itself without getting anywhere, the guard halts the loop rather than burning time and money going nowhere.

🔒

A sensitive-data checkpoint

Some information should never leave the building — a customer’s medical record, a secret contract. Before a tool runs, a data-classification checkpoint can look at what’s about to be touched and, depending on how it’s configured, block the action outright or let it run but scrub the sensitive parts out of the result.

The sensitive-data checkpoint, a little closer

This checkpoint is the harness’s tie-in to Microsoft Purview-style data governance. It runs as its own gate, separate from the permission rulebook, and it has three settings so an organisation can adopt it gradually:

Off

The checkpoint does nothing. This is the default — nothing changes until an organisation deliberately turns it on.

👁

Audit

The checkpoint watches and records what sensitive data was touched, but never blocks anything. A safe way to see what would happen before enforcing.

🛑

Enforce

The checkpoint acts: a “block” verdict stops the tool and tells the agent why; a “redact” verdict lets the tool run, then scrubs the sensitive parts out of the answer.

🔗
Three gates, stacked

Put together, a tool call now passes through three checkpoints in a row before it runs: “are you allowed?” (governance and the trust dial), “is this data safe to touch?” (the sensitive-data checkpoint), and “are you actually making progress?” (the spin detector). Each is off by default and switched on when an organisation is ready for it — the harness ships cautious and lets you tighten the screws over time.

Putting It Together — The Closed Loop

Five systems, one feedback cycle. Each one feeds the next, and the cycle as a whole makes the harness improve over time without manual intervention on every turn.

Here is the loop in motion. Drift detection notices an agent slipping. The next risky call gets caught by governance — either because policies have tightened, or because the autonomy tier was downgraded after the drift alert. Escalation surfaces it to a human. The human’s decision is captured as a learning. The learning shifts the agent’s future behavior, which shifts its quality scores, which (eventually) brings drift back into the green. Round and round.

D
Drift
A
Autonomy
G
Governance
E
Escalation
L
Learnings
P
Proposer
Click “Next Step” to follow one full turn of the quality loop.
💡
There’s no QualityLoopService class

The “quality loop” is the name we give to the emergent behavior of these five subsystems working together. There’s no single class called QualityLoopService. The loop is a property of the architecture, not of any one file — which is why it works without any system coordinator coupling everything together.

Final scenario quiz

Your agent asks for file_system.write on /etc/hosts. Trace the order of systems that fire.

An agent produces one bad answer in 100 turns. Should drift detection fire?

You captured a learning: “agent X tends to hallucinate dates after model deployment v3.7”. Which decay class?

You’re launching a brand-new agent in production. Which autonomy tier do you start at?

Two Loops Forward — Reinforcement & the Proposer

Decay shows how learnings fade. This screen shows how they grow stronger — and how a second, offline loop edits the harness itself when the online loop alone isn’t enough.

Screen 5 covered one half of the learnings story: freshness decay, the age-based score that lets old wisdom expire. The other half is reinforcement: every time a learning is used and rated, its FeedbackWeight moves toward the new signal via an exponential moving average. Decay is what time does to a learning if nobody touches it; reinforcement is what feedback does when somebody does.

And one level above the online loop, a second loop runs offline: the harness-proposer meta-agent reads execution traces from prior runs and proposes structural edits — to skill files, prompts, or config — that an evaluator then scores against a benchmark. The online loop tunes weights. The offline loop edits the harness.

Online loop — ImproveLearningCommandHandler

When a turn finishes and the user (or an evaluator) supplies a feedback score from 1 to 5, the harness sends an ImproveLearningCommand for each learning that influenced the answer. The handler normalizes the score to [0, 1] and folds it into the existing weight with an EMA.

CODE

var config = _options.CurrentValue.AI.Learnings;
var alpha = config.FeedbackAlpha;
var normalized = (request.FeedbackScore - 1.0) / 4.0;
var newWeight = alpha * normalized + (1 - alpha) * learning.FeedbackWeight;

if (config.BiasCorrection && learning.UpdateCount < 5)
{
    var correctionFactor = 1.0 / (1.0 - Math.Pow(1.0 - alpha, learning.UpdateCount + 1));
    newWeight = Math.Clamp(newWeight * correctionFactor, 0.0, 1.0);
}

var updated = learning with
{
    FeedbackWeight = newWeight,
    UpdateCount = learning.UpdateCount + 1,
    LastReinforcedAt = _timeProvider.GetUtcNow(),
    Content = request.ReinforcementContent ?? learning.Content
};

var updateResult = await _store.UpdateAsync(updated, ct);
var bridgeResult = await _driftBridge.CheckAndAdjustBaselineAsync(updated, ct);
LearningsMetrics.Improved.Add(1);
        
PLAIN ENGLISH

Pull the learnings config — alpha controls how much each new score moves the weight.

Typical alpha is small (0.1–0.3), so weights move smoothly rather than snapping to every rating.

Convert a 1–5 score into a 0–1 signal. A 5 maps to 1.0, a 1 maps to 0.0, a 3 maps to 0.5.

The EMA step: the new weight blends the latest signal (alpha) with the existing weight (1 - alpha).

 

Bias-correction guard: the first few updates are biased toward zero because the weight starts at zero.

 

Standard EMA bias-correction factor: 1 / (1 - (1 - alpha)^t).

Apply it and clamp so we don’t overshoot [0, 1] on a strong early signal.

 

 

Build a new immutable learning record with the updated values.

 

The new weight after EMA + correction.

Bump the update counter — future calls won’t apply bias correction past 5.

Reset the freshness clock: this learning has just been reinforced.

Optionally update the natural-language content if the caller passed a refinement.

 

 

Persist via the configured ILearningsStore (in-memory or graph-backed).

Notify the drift bridge — if this learning’s domain matches an EWMA baseline, the baseline may adjust.

Increment the OpenTelemetry counter for observability dashboards.

 

Worked example — four 5-star updates on a fresh learning

Take alpha = 0.2, starting weight 0.0, and feed four perfect (5-star ⇒ normalized 1.0) ratings. With bias correction, here is the trajectory:

t=1 raw = 0.2, corrected = 0.2 / (1 - 0.81) = 1.000 — one perfect signal is enough at first
t=2 raw = 0.2·1 + 0.8·1.0 = 1.0, corrected = 1.0 / (1 - 0.64) = 1.000 (clamped)
t=3 raw = 1.0, corrected and clamped = 1.000 — the learning is now fully reinforced
t=4 raw = 1.0, corrected and clamped = 1.000

Now imagine the fifth turn produces a 2-star rating (normalized = 0.25). Past UpdateCount = 5 the correction stops; the EMA cleanly absorbs the signal: 0.2 · 0.25 + 0.8 · 1.0 = 0.85. The learning is still trusted — but a sustained run of low scores would erode the weight smoothly. No single bad turn flips the bit.

💡
Why EMA + bias correction

Plain EMA on a weight that starts at zero severely underweights the first few updates — a brand-new learning would never reach its true weight, no matter how good the feedback is. Bias correction (the same trick Adam uses for gradient moments) lifts the early updates back to where they would be in steady state, then quietly retires after 5 updates so the long-run behavior is pure EMA. The harness disables correction via config.BiasCorrection = false if you want strict EMA.

The drift bridge — reinforcement feeds the canary

After every successful weight update, ImproveLearningCommandHandler calls ILearningsDriftBridge.CheckAndAdjustBaselineAsync. The bridge asks: does this newly reinforced learning belong to a domain that has an EWMA baseline? If yes, and if the cumulative reinforcement crosses a threshold, the baseline mean is shifted. Drift detection (Screen 5) keeps comparing the live EWMA against a baseline that now reflects the team’s accumulated corrections — rather than a baseline frozen at launch day.

Bridge failure is non-critical. The learning update always succeeds independently; a failed bridge call is logged at Warning level and the loop continues. The bridge is an enrichment, not a gate.

Offline loop — the harness-proposer meta-agent

Reinforcement tunes weights. It cannot rewrite a bad system prompt or fix a misaligned skill. That is what the offline loop is for. The harness-proposer agent (in agents/harness-proposer/AGENT.md, mirrored by OptimizeExample in Presentation.ConsoleUI) is a meta-agent: it reads what other agents did, and proposes targeted edits to their configuration.

It expects three artifacts from a completed evaluation run, all newline-delimited JSON:

T
traces.jsonl

One line per turn — tool calls, intermediate thoughts, retrieval hits, final answer. The agent’s playback tape.

D
decisions.jsonl

Choice points: which skill the orchestrator picked, which tool was selected, which planner branch was taken. The proposer reads these to find where behavior could be steered.

C
candidates/index.jsonl

The benchmark set — reference questions with expected answers. The proposer optimizes for pass rate against this set, not for any single conversation.

From these three inputs, it produces a list of proposed edits — targeted, small, and reviewable. A separate evaluator agent (or human) scores each proposal: apply, modify, or reject. The winner becomes the new baseline; traces from the next run feed the next iteration. Four phases per cycle: trace → propose → evaluate → apply.

💡
Why the proposer is a separate agent, not a service

Making the optimizer an agent buys two things at once. First, the proposer can use the same skill, tool, and governance machinery the rest of the harness already enforces — including escalation, audit, and content safety on its own edits. Second, the proposer is itself replaceable: ship a smarter proposer (or a different optimization strategy) by swapping the skill, not by rewriting infrastructure. The four-phase loop stays the same; the brain inside it can evolve.

Online vs offline — side by side

Online — Reinforcement

When: on every feedback event.
What changes: a single learning’s FeedbackWeight.
Risk: low — bounded by EMA + clamp.
Human in the loop: implicit (feedback score).
Mental model: nudging the dials.

Offline — Proposer

When: after a benchmark run completes.
What changes: skill files, prompts, config.
Risk: higher — structural edits.
Human in the loop: explicit (evaluator phase).
Mental model: rewriting the playbook.

Quick check

A well-established learning (weight 1.0) gets a single 1-star rating. What happens?

An agent’s system prompt has a subtle bug that causes it to misroute 20% of requests. Which loop fixes this?

The loop closes here — one module left.

You’ve seen the harness as it stands today: seven modules covering what it is, who’s in the cast, how a conversation flows, how skills load, how tools fire safely, how the system watches itself, and how to trace any of it end-to-end in the code. This eighth module shows what changes when those same systems start learning from their own behavior — on two timescales.

The online loop — governance → escalation → learnings, with EMA reinforcement and drift-bridged baselines — tunes the harness continuously, one rating at a time. The offline loop — the harness-proposer reading traces and editing skills — rewrites the playbook when tuning isn’t enough. Together they answer the hardest question in production AI: is my agent getting worse, and can I prove it?

Now go put a real agent through both loops. Watch the EWMA scores in the Quality dashboard. Trigger an escalation and resolve it. Create a learning and reinforce it across a few turns. Then run the proposer over the traces and read what it suggests. That’s when the patterns from these modules become muscle memory. And there’s one more module ahead — where the agent stops only telling you things and starts showing and doing them on the screen in front of you.

09

The Agent Acts & Shows Its Work

Everything so far pictured the agent as something that talks — you ask, it answers in words. This last module is about the two ways it steps out of the chat box: it can show you things you can look at and click, and it can act on the screen in front of you.

Think of the difference between a travel agent who reads you a list of flights over the phone, and one sitting next to you who pulls up the booking screen, lays the options out as a table, hands you a form to pick your seat, and then clicks through the booking while you watch. Same job — but the second one is showing their work and doing the work, right in front of you. That’s what this module is about.

📊

Showing — interactive widgets in the chat

Instead of describing data in a paragraph, the agent can drop a real chart, table, image, or fill-in form straight into the conversation for you to read or use.

🖥

Acting — an agent that drives the screen

Embedded inside a live dashboard, the agent can change the date range, jump to another page, refresh the data, and read what’s currently on screen — doing the clicking for you.

Showing Its Work — Widgets Instead of Walls of Text

In Module 5 you met these as ordinary tools. Here’s what they actually put on your screen.

When the agent decides that a picture would say more than a paragraph, it reaches for a special kind of tool. To the agent it’s just another tool call — but instead of returning text, the harness renders the result as a live element right there in the chat. There are four flavours:

🖼

An image

A picture or diagram shown inline — not a link you have to open, but the actual image sitting in the reply.

📋

A table

Rows and columns laid out cleanly, so you can scan numbers or compare items at a glance instead of parsing a run-on sentence.

📈

A chart

A bar, line, or pie chart drawn from the data the agent gathered — the trend jumps out visually rather than hiding in a list of figures.

📝

A form

Actual fields you fill in — a date, a dropdown, a yes/no — and when you submit, your answers travel straight back to the agent so it can continue with real input from you.

The form is the interesting one, because it turns a one-way announcement into a back-and-forth. The agent hands you a form, you fill it in and hit submit, and your answers become the agent’s next piece of information — a genuine conversation conducted partly through the interface rather than only through typing.

💾
The widgets stick around

A chart or a filled-in form isn’t a throwaway that vanishes when you reload the page. Once you’ve confirmed a widget, it’s saved as part of the conversation’s history, so it’s still there when you come back later. (There’s a small piece of care here: the harness only saves a widget after you’ve actually confirmed it, so a form you never submitted doesn’t leave a ghost behind.)

Doing the Work — An Agent That Drives the Dashboard

Showing you a chart is helpful. Changing the screen for you is a bigger leap — and it’s where the agent stops being a narrator and becomes a co-pilot.

Picture one of the harness’s monitoring dashboards — charts, filters, a date picker, several pages. Now picture a small chat panel tucked into the corner of that same dashboard. You can ask that panel things in plain language, and instead of just answering, it can operate the dashboard for you. That’s the acting agent.

📅

Set the time range

“Show me the last 24 hours” — and the date filter actually moves, redrawing every chart on the page.

🧭

Navigate

“Take me to the errors page” — and the dashboard switches views, saving you the hunt through the menu.

🔄

Refresh

“Pull the latest numbers” — and it re-fetches the data so you’re looking at the current state.

👁

Read what’s on screen

It can also look at the dashboard’s current state — which range is selected, what’s showing — so its answers match what you’re actually seeing.

And it can still show at the same time: ask it a question about the data and it can draw a fresh chart right in the chat panel, on top of driving the page. So in one exchange it can read the current view, change the time range, and hand you a new chart of what changed — all from a sentence you typed in plain language.

🤝
Why this is the natural finish line

Every earlier module made the agent more capable behind the screen — better memory, safer tools, tighter oversight. This module is where all of that turns outward. The same governance, the same permission gates, and the same trust dial from Module 8 still apply when the agent acts on your dashboard — acting on the UI is just another set of tools, watched by the same guards. The payoff is an assistant you don’t just read, but work alongside.

Quick check

1. How does the agent “draw you a chart” in the chat?

2. What makes the embedded dashboard agent different from an ordinary chatbot?

That’s the whole tour.

You started with a black box that answers questions and ended with an assistant that keeps a diary of its own work, refuses to trust every note in it, watches its own budget and its own progress, guards sensitive data, and — in this last module — steps out of the chat box to show you charts and forms and to act on the screen in front of you.

None of it is magic. Every capability you’ve seen is a small, inspectable piece — a tool, a gate, a memory, a widget — wired together with care. That’s the whole idea of a harness: not one clever trick, but a lot of honest parts you can trace, one at a time.