Chapter 04 · Architecture

A Message's Journey

You typed a question into the console. Several seconds later, an answer appeared. What happened in between is the most important thing to understand in this codebase. We'll trace it step by step, file by file, with real code excerpts from this repo.

The cast of characters

Before the trace, meet the players. We'll refer to each by name throughout.

App.cs
Presentation entry — owns the Spectre.Console menu and routes to example classes.
ResearchAgentExample
An example class that builds an ExecuteAgentTurnCommand and dispatches it via MediatR.
MediatR pipeline
A series of IPipelineBehavior wrappers (validation, content safety, governance, etc.) that every command flows through.
ExecuteAgentTurnCommandHandler
The handler that actually invokes the agent for a single turn.
AgentConversationCache
Caches built agents per (conversationId, skillId) so subsequent turns reuse the same configured instance.
AgentFactory
Builds a new agent — loads the skill, resolves tools, wires content safety, attaches OTel.
ChatClientFactory
Creates the LLM client (Azure OpenAI, OpenAI, or AI Foundry) from AppConfig.AI.AgentFramework.
The agent
A Microsoft.Agents.AI.AIAgent — assembles the prompt, calls the LLM, dispatches tool calls, loops until done.

Step-by-step trace

  1. Step 1 · Presentation
    You type a message and press Enter

    App.RunAsync() sits in a menu loop. When you pick "Research Agent", it calls ResearchAgentExample.RunAsync(). That example reads your prompt from the console, then builds a strongly-typed command:

    C# · ExecuteAgentTurnCommand
    var cmd = new ExecuteAgentTurnCommand
    {
        AgentName       = "research",     // matches a SKILL.md ID
        UserMessage     = userInput,
        ConversationId  = sessionGuid,
        TurnNumber      = currentTurn,
        ConversationHistory = priorMessages
    };
    var result = await _mediator.Send(cmd, cancellationToken);

    Everything from here on flows through MediatR. The example doesn't know anything about Azure OpenAI, the skill loader, or tools. It just sends a command. That's the Application boundary doing its job.

    MediatR, briefly

    A C# library implementing the mediator pattern. You define a record Command : IRequest<TResponse>; somewhere else you define class Handler : IRequestHandler<Command, TResponse>; a third party calls _mediator.Send(command) and MediatR finds the right handler. Why? Because pipeline behaviors can wrap every command for cross-cutting concerns (validation, logging, retries) without the handlers or callers knowing about them. We use this heavily.

  2. Step 2 · Application — pipeline behaviors
    The command flows through the MediatR pipeline

    Before the handler runs, the command is wrapped in pipeline behaviors — one for each cross-cutting concern. Each behavior can pre-process the request, call the next behavior, and post-process the response. Registration order matters; the order roughly is:

    1. UnhandledExceptionBehavior — outer wrapper, converts uncaught exceptions to logged errors.
    2. AmbientRequestScopeBehavior — establishes the ambient request scope the inner behaviors read from.
    3. AgentContextPropagationBehavior — copies agent/conversation IDs into ambient tracing tags.
    4. AgentIdentityResolutionBehavior — resolves the caller's tenant/owner identity for scope isolation.
    5. AuditTrailBehavior — persistent audit log of every command.
    6. ContentSafetyBehavior — checks input against configured content filters.
    7. PromptInjectionBehavior — runs deterministic prompt-injection detectors on user input.
    8. TokenBudgetBehavior — enforces the per-turn token budget.
    9. HookBehavior — invokes any configured pre/post hooks.
    10. RetrievalAuditBehavior — records RAG retrievals for the turn.
    11. ResponseSanitizationBehaviorpost-execution; scrubs the assistant response.
    12. ToolOutputCompressionBehaviorpost-execution; compresses large tool outputs by content type to reduce token usage.
    13. KnowledgeExtractionBehaviorpost-turn, fire-and-forget; extracts knowledge-graph facts.
    14. WorkEpisodeCaptureBehaviorpost-turn, fire-and-forget; captures a work-memory episode.
    15. PromptUsageTrackingBehavior — records prompt/token usage metrics.

    There is no GovernancePolicyBehavior or ToolPermissionBehavior in this list — those two were deleted in PR #90 (they keyed on an IToolRequest marker nothing implemented, so they never fired). Tool authorization happens on the execution path via GovernedAIFunction instead — see Tools & Keyed DI.

    Why so many?

    Each one is independent and turn-off-able. Production agents need most of them; unit tests can swap to a bare pipeline. The behaviors are how we keep the handler focused on its one job (executing the turn) while still getting observability, validation, and safety for free.

    If any behavior decides to abort — e.g. validation fails — the pipeline short-circuits and returns a failure Result without ever reaching the handler. Here's the validation behavior boiled down:

    C# · RequestValidationBehavior
    var results = await Task.WhenAll(
        validators.Select(v => v.ValidateAsync(context, cancellationToken)));
    
    if (results.SelectMany(r => r.Errors).Any())
        return Result.ValidationFailure(errorMessages);
    
    return await next();   // call the next behavior / handler
  3. Step 3 · Application — handler entry
    ExecuteAgentTurnCommandHandler.Handle()

    After all behaviors green-light the request, the handler runs. Its job has four parts: resolve the skill, get or build an agent, run the turn, record telemetry.

    Resolving the skills. The incoming AgentName might be an agent ID (from the manifest, which references one or more skills) or a single skill ID directly. An agent's Skills is a list — the harness supports multi-skill agents — so resolution yields a set of skill IDs, falling back to treating the name as a single skill ID:

    C# · skill resolution
    var skillIds = _agentRegistry.TryGet(request.AgentName)?.Skills
                   ?? [request.AgentName];
  4. Step 4 · Application — agent build/cache
    Get the agent from cache, or build a new one

    AgentConversationCache.GetOrCreateAsync() keys by (conversationId, skillId). If an agent for that key already exists, it's reused (preserving any in-memory state). Otherwise, AgentFactory builds one — CreateAgentFromSkillsAsync(...) for a skill set, or CreateAgentAsync(context) from an assembled execution context. AgentFactory lives in Application.AI.Common/Factories/. Building a new agent is non-trivial:

    • Look up the SkillDefinition(s) from ISkillMetadataRegistry (parsed from SKILL.md at startup).
    • Read the skill's allowed-tools declaration. Resolve each one from the keyed DI container.
    • Convert internal ITool implementations to AITool via AIToolConverter — see Tools & Keyed DI.
    • Create the chat client through IChatClientFactory (Azure OpenAI, OpenAI, or AI Foundry depending on config).
    • Wrap the chat client with middleware: content safety, OTel, function-invocation limits.
    • Construct a Microsoft.Agents.AI.AIAgent with the system prompt (assembled from skill instructions + any override) and the tool list.
    Why cache agents per conversation?

    Building an agent costs real work (file I/O for the skill, tool resolution, client construction). Within a single conversation, every turn uses the same skill — caching the built agent skips that work on turn 2+. The cache is keyed by conversationId, so different conversations get independent agents.

  5. Step 5 · Handler — invoke the agent
    agent.RunAsync(messages)

    The handler builds a message list — prior conversation history plus the new user message — and calls the agent. Behind the scenes, Microsoft.Agents.AI does this loop:

    1. Serialize messages + tool schemas into an LLM request.
    2. Send the request to the chat client (Azure OpenAI / OpenAI).
    3. If the response includes a tool_call, dispatch the call to the matching AITool and append its result to the messages.
    4. Loop until the response has no more tool calls — that's the agent's final answer.

    The handler captures token usage and tool names from an ambient LlmUsageCapture that the OTel middleware writes to during the run:

    C# · ExecuteAgentTurnCommandHandler.cs
    _usageCapture.TakeSnapshot();           // clear stale data
    LlmUsageCapture.Current = _usageCapture; // ambient scope
    
    response = await agent.RunAsync(messages, cancellationToken: ct);
    
    var usage = _usageCapture.TakeSnapshot(); // total tokens, cost, tools called
    i
    Streaming and loop guards on this same call

    Two things happen around this one call that are worth knowing about.

    1 · Real token streaming. Instead of waiting for the complete answer, a turn-driver can call RunStreamingAsync and forward each fragment through IAgentTurnStreamSink the moment it arrives. (One caveat: the AG-UI browser transport still waits for the whole response and then re-chunks it, so streaming is not yet end-to-end all the way to the browser.)

    2 · Loop guards. Every tool call inside the loop goes through GovernedAIFunction, which watches for two failure modes an agent can fall into on its own:

    • Going in circles — the spin guard (IProgressEvaluator) halts an agent that keeps making the same call over and over without getting anywhere.
    • Running up the billIConversationBudgetTracker tracks tokens across the whole conversation, not just this turn, and breaks the run gracefully once it hits its ceiling.
  6. Step 6 · Telemetry & result
    Record what happened, return the result

    Before returning, the handler:

    • Records the user message and assistant response in IObservabilityStore (durable audit).
    • Emits OTel metrics: TurnDuration, TurnsTotal, per-tool Invocations.
    • Builds an AgentTurnResult with the response text, updated history, tools invoked, token counts, and USD cost.

    The AgentTurnResult flows back through the pipeline (in reverse), gets logged by the audit behavior, and returns to ResearchAgentExample which prints it to the console.


The whole journey in one diagram

flow
You ──▶ Console
        │
        ▼
   ResearchAgentExample
        │  builds command
        ▼
   IMediator.Send(cmd)
        │
        ▼
   ┌────────────── MediatR pipeline (outer→inner) ─────────────┐
   │  UnhandledException → AmbientScope → ContextPropagation →  │
   │  IdentityResolution → AuditTrail → ContentSafety →         │
   │  PromptInjection → TokenBudget → Hook → RetrievalAudit →   │
   │  [handler] → ResponseSanitization → ToolOutputCompression →│
   │  KnowledgeExtraction → WorkEpisodeCapture → PromptUsage    │
   └──────────────────────────┬────────────────────────────────┘
                      ▼
   ExecuteAgentTurnCommandHandler
        │ resolve skill IDs (Skills list)
        ▼
   AgentConversationCache.GetOrCreateAsync
        │ cache miss?
        ▼
   AgentFactory.CreateAgentFromSkillsAsync(skillIds)
        │  looks up SKILL.md metadata
        │  resolves keyed tools (wrapped by GovernedAIFunction)
        │  wires content safety + OTel
        ▼
   ChatClientFactory.CreateAsync
        │  AzureOpenAI / OpenAI / AIFoundry
        ▼
   agent.RunAsync / RunStreamingAsync(messages)
        │  ┌─ LLM call (prompt + tool schemas)
        │  │  ┐
        │  └─ if tool_call → GovernedAIFunction gates → dispatch → result → loop
        │     ┘
        ▼
   AgentTurnResult { text, tokens, cost, tools[] }
        │  observability store + metrics
        ▼  unwinds back through pipeline
   Console prints the response.

What this means for you as a developer

Three things to take away:

  1. You almost never need to modify the handler. 95% of changes happen in skills, tools, or behaviors. Adding a new capability is usually "register a new tool + reference it from a skill" — never "change the journey."
  2. Cross-cutting features live in behaviors, not in handlers. Need to log every request to a custom store? Write a IPipelineBehavior. Don't sprinkle _logger.Log() calls inside handlers.
  3. The agent loop is owned by Microsoft.Agents.AI. You don't write the tool-call dispatch yourself — you give the agent tools, and it does the loop. If you want different behavior, that's the boundary to look at.

Where to go from here