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.csResearchAgentExampleExecuteAgentTurnCommand and dispatches it via MediatR.IPipelineBehavior wrappers (validation, content safety, governance, etc.) that every command flows through.ExecuteAgentTurnCommandHandlerAgentConversationCache(conversationId, skillId) so subsequent turns reuse the same configured instance.AgentFactoryChatClientFactoryAppConfig.AI.AgentFramework.Microsoft.Agents.AI.AIAgent — assembles the prompt, calls the LLM, dispatches tool calls, loops until done.Step-by-step trace
-
You type a message and press Enter
App.RunAsync()sits in a menu loop. When you pick "Research Agent", it callsResearchAgentExample.RunAsync(). That example reads your prompt from the console, then builds a strongly-typed command:C# · ExecuteAgentTurnCommandvar 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, brieflyA C# library implementing the mediator pattern. You define a
record Command : IRequest<TResponse>; somewhere else you defineclass 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. -
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:
UnhandledExceptionBehavior— outer wrapper, converts uncaught exceptions to logged errors.AmbientRequestScopeBehavior— establishes the ambient request scope the inner behaviors read from.AgentContextPropagationBehavior— copies agent/conversation IDs into ambient tracing tags.AgentIdentityResolutionBehavior— resolves the caller's tenant/owner identity for scope isolation.AuditTrailBehavior— persistent audit log of every command.ContentSafetyBehavior— checks input against configured content filters.PromptInjectionBehavior— runs deterministic prompt-injection detectors on user input.TokenBudgetBehavior— enforces the per-turn token budget.HookBehavior— invokes any configured pre/post hooks.RetrievalAuditBehavior— records RAG retrievals for the turn.ResponseSanitizationBehavior— post-execution; scrubs the assistant response.ToolOutputCompressionBehavior— post-execution; compresses large tool outputs by content type to reduce token usage.KnowledgeExtractionBehavior— post-turn, fire-and-forget; extracts knowledge-graph facts.WorkEpisodeCaptureBehavior— post-turn, fire-and-forget; captures a work-memory episode.PromptUsageTrackingBehavior— records prompt/token usage metrics.
There is no
GovernancePolicyBehaviororToolPermissionBehaviorin this list — those two were deleted in PR #90 (they keyed on anIToolRequestmarker nothing implemented, so they never fired). Tool authorization happens on the execution path viaGovernedAIFunctioninstead — 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
Resultwithout ever reaching the handler. Here's the validation behavior boiled down:C# · RequestValidationBehaviorvar 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 -
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
AgentNamemight be an agent ID (from the manifest, which references one or more skills) or a single skill ID directly. An agent'sSkillsis 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 resolutionvar skillIds = _agentRegistry.TryGet(request.AgentName)?.Skills ?? [request.AgentName]; -
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,AgentFactorybuilds one —CreateAgentFromSkillsAsync(...)for a skill set, orCreateAgentAsync(context)from an assembled execution context.AgentFactorylives inApplication.AI.Common/Factories/. Building a new agent is non-trivial:- Look up the
SkillDefinition(s) fromISkillMetadataRegistry(parsed fromSKILL.mdat startup). - Read the skill's
allowed-toolsdeclaration. Resolve each one from the keyed DI container. - Convert internal
IToolimplementations toAIToolviaAIToolConverter— 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.AIAgentwith 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. - Look up the
-
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.AIdoes this loop:- Serialize messages + tool schemas into an LLM request.
- Send the request to the chat client (Azure OpenAI / OpenAI).
- If the response includes a
tool_call, dispatch the call to the matchingAITooland append its result to the messages. - 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
LlmUsageCapturethat 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 calledStreaming and loop guards on this same callTwo 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
RunStreamingAsyncand forward each fragment throughIAgentTurnStreamSinkthe 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 bill —
IConversationBudgetTrackertracks tokens across the whole conversation, not just this turn, and breaks the run gracefully once it hits its ceiling.
-
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-toolInvocations. - Builds an
AgentTurnResultwith the response text, updated history, tools invoked, token counts, and USD cost.
The
AgentTurnResultflows back through the pipeline (in reverse), gets logged by the audit behavior, and returns toResearchAgentExamplewhich prints it to the console. - Records the user message and assistant response in
The whole journey in one diagram
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:
- 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."
- 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. - 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.