Build an Evidence-First Incident Triage Agent with Microsoft Agent Framework
Create a tool-using C# agent that retrieves deterministic operational evidence, keeps runbook guidance separate from facts, streams its assessment, and reuses the same AgentSession for a follow-up—without pretending that correlation proves root cause.
What You Will Build
This tutorial builds an incident-triage agent around a small synthetic outage. The interesting part is not the outage itself; it is the boundary between what the tools actually return and what the model is allowed to infer.
checkout-api incident
│
▼
IncidentTriageAgent
│
├── get_service_health ───────► E-101
├── get_recent_deployment ────► E-201
└── get_runbook ──────────────► RB-CHECKOUT-03
│
▼
OBSERVED EVIDENCE
REASONABLE INFERENCES
RUNBOOK GUIDANCE
UNKNOWNS
UNPROVEN CLAIMS
│
▼
same AgentSession
│
▼
"Did deploy-1842 cause the outage?"Deterministic evidence
Service and deployment records are embedded JSON, so every reader begins with the same incident facts.
Three read-only tools
The model can retrieve health evidence, deployment evidence, and runbook guidance—but it cannot restart or roll back anything.
Probabilistic interpretation
The model may reason over evidence, but missing fields remain missing and causal claims must stay unproven unless evidence supports them.
Why Tool Access Is Not Enough
A common mental model is: “Once the agent has tools, hallucination is solved.” That is too optimistic. A model can retrieve correct records and still invent a plausible bridge between them.
Tool result What the agent may say ──────────────────────────────────────────────────────────────────────────── previous_deployment=deploy-1839 "deploy-1839 was the previous deployment" NO timestamp for deploy-1839 "its completion time is UNKNOWN" deploy-1842 completed at 14:02 error spike began at 14:09 "the deployment is worth investigating" same chronology NOT: "the deployment caused the outage"
The missing timestamp is the most useful teaching detail in this sample. The deployment tool returns the identifier deploy-1839 but never returns a completion time for it. A grounded agent should preserve that gap instead of quietly completing the story.
Prerequisites and Verified Baseline
This page is tied to a concrete, tested baseline rather than a floating “latest” package claim.
| Component | Verified value |
|---|---|
| Target framework | net10.0 |
| .NET SDK baseline | 10.0.401 |
| Solution format | .slnx |
| Direct Agent Framework package | Microsoft.Agents.AI.OpenAI 1.22.0 |
Resolved Microsoft.Agents.AI | 1.22.0 |
Resolved Microsoft.Extensions.AI | 10.10.0 |
| Resolved OpenAI package | 2.13.0 |
| Deterministic tests | 14 passed, 0 failed |
| Live verification provider/model | OpenRouter / openai/gpt-4o-mini |
{
"sdk": {
"version": "10.0.401",
"rollForward": "latestFeature",
"allowPrerelease": false
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.22.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Data/services.json" />
<EmbeddedResource Include="Data/deployments.json" />
<EmbeddedResource Include="Data/runbooks.json" />
</ItemGroup>
</Project>
Understand the Agent Framework Pieces
Before the code, separate the framework concepts. Microsoft Agent Framework provides a common AIAgent abstraction, and its chat-client-backed implementation works through IChatClient. Conversation state is represented by AgentSession, while ordinary C# methods can be wrapped as model-callable functions with AIFunctionFactory.Create.
IChatClient
The provider boundary. The rest of this sample can depend on the abstraction instead of knowing how OpenRouter was configured.
AIAgent
The primary agent abstraction used by application code.
ChatClientAgent
The concrete runtime agent created from an IChatClient in this sample.
AgentSession
Conversation state reused between the first incident assessment and the follow-up.
AIFunction
A model-callable function definition. Here it wraps deterministic read-only methods.
RunStreamingAsync
Streams AgentResponseUpdate items as the model produces its answer.
AgentSession and CreateSessionAsync(). When examples disagree, check the package version before copying API names.Project Architecture
The architecture separates provider configuration, deterministic sources, tool adapters, and model reasoning. That separation is more important than the number of classes.
Program.cs
│
provider-specific configuration
│
▼
IChatClient
│
▼
TriageAgentFactory
instructions + tools
│
▼
AIAgent
│
▼
AgentSession
│
┌───────────────┼───────────────┐
▼ ▼ ▼
ServiceHealthTool DeploymentTool RunbookTool
│ │ │
└───────┬───────┘ │
▼ ▼
EvidenceStore RunbookStore
│ │ │
▼ ▼ ▼
services.json deployments.json runbooks.jsonObserved evidence and runbook guidance intentionally have separate stores. That makes it harder to accidentally turn “check the connection pool” into “the connection pool was exhausted.”
Create Deterministic Evidence
Start with facts that exist independently of the model. EvidenceItem is deliberately boring: it stores an ID, type, service, timestamp, and a dictionary of observed facts.
namespace IncidentTriageAgent.Evidence;
/// <summary>
/// Represents one deterministic piece of operational evidence.
///
/// Evidence items contain observed facts only.
/// They must not contain model-generated conclusions or inferred facts.
/// </summary>
public sealed record EvidenceItem(
string Id,
string Type,
string Service,
DateTimeOffset ObservedAt,
IReadOnlyDictionary<string, string> Facts)
{
/// <summary>
/// Returns a fact value when the field exists.
/// Missing fields remain missing rather than being inferred.
/// </summary>
public string? GetFact(string key)
{
return Facts.TryGetValue(key, out string? value)
? value
: null;
}
}
The store loads fixed JSON embedded into the assembly. That gives tests and readers a stable incident instead of requiring access to a monitoring account.
using System.Reflection;
using System.Text.Json;
namespace IncidentTriageAgent.Evidence;
/// <summary>
/// Loads deterministic operational evidence from the sample's
/// embedded JSON data files.
///
/// The evidence is intentionally fixed so that tool behavior and
/// automated tests remain repeatable without a live monitoring system.
/// </summary>
public sealed class EvidenceStore
{
private static readonly JsonSerializerOptions JsonOptions =
new()
{
PropertyNameCaseInsensitive = true
};
private readonly IReadOnlyList<EvidenceItem> _items;
public EvidenceStore()
{
List<EvidenceItem> items = [];
items.AddRange(
LoadEvidence("services.json"));
items.AddRange(
LoadEvidence("deployments.json"));
_items = items;
}
/// <summary>
/// Returns the newest evidence item of the requested type
/// for the requested service.
/// </summary>
public EvidenceItem? GetLatest(
string serviceName,
string evidenceType)
{
if (string.IsNullOrWhiteSpace(serviceName) ||
string.IsNullOrWhiteSpace(evidenceType))
{
return null;
}
string service =
Normalize(serviceName);
string type =
evidenceType
.Trim()
.ToLowerInvariant();
return _items
.Where(item =>
item.Service.Equals(
service,
StringComparison.OrdinalIgnoreCase) &&
item.Type.Equals(
type,
StringComparison.OrdinalIgnoreCase))
.OrderByDescending(
item => item.ObservedAt)
.FirstOrDefault();
}
/// <summary>
/// Returns all evidence currently available for a service.
/// </summary>
public IReadOnlyList<EvidenceItem> GetForService(
string serviceName)
{
if (string.IsNullOrWhiteSpace(serviceName))
{
return [];
}
string service =
Normalize(serviceName);
return _items
.Where(item =>
item.Service.Equals(
service,
StringComparison.OrdinalIgnoreCase))
.OrderBy(
item => item.ObservedAt)
.ToList();
}
/// <summary>
/// Loads one embedded JSON evidence file.
///
/// Resource discovery uses the filename suffix rather than
/// assuming a particular root namespace.
/// </summary>
private static IReadOnlyList<EvidenceItem> LoadEvidence(
string fileName)
{
Assembly assembly =
typeof(EvidenceStore).Assembly;
string expectedSuffix =
$".Data.{fileName}";
string? resourceName =
assembly
.GetManifestResourceNames()
.SingleOrDefault(name =>
name.EndsWith(
expectedSuffix,
StringComparison.OrdinalIgnoreCase));
if (resourceName is null)
{
throw new InvalidOperationException(
$"Embedded evidence file '{fileName}' was not found.");
}
using Stream stream =
assembly.GetManifestResourceStream(
resourceName)
?? throw new InvalidOperationException(
$"Embedded evidence file '{fileName}' could not be opened.");
List<EvidenceItem>? evidence =
JsonSerializer.Deserialize<List<EvidenceItem>>(
stream,
JsonOptions);
return evidence
?? throw new InvalidOperationException(
$"Embedded evidence file '{fileName}' contained no valid data.");
}
private static string Normalize(
string serviceName)
{
return serviceName
.Trim()
.ToLowerInvariant();
}
}
Service-health evidence
[
{
"Id": "E-101",
"Type": "service_health",
"Service": "checkout-api",
"ObservedAt": "2026-09-24T14:12:00Z",
"Facts": {
"status": "degraded",
"error_rate": "18.7%",
"error_spike_started": "2026-09-24T14:09:00Z",
"database_pool_utilization": "96%"
}
},
{
"Id": "E-102",
"Type": "service_health",
"Service": "catalog-api",
"ObservedAt": "2026-09-24T14:12:00Z",
"Facts": {
"status": "healthy",
"error_rate": "0.2%",
"database_pool_utilization": "34%"
}
}
]
Deployment evidence
[
{
"Id": "E-201",
"Type": "deployment",
"Service": "checkout-api",
"ObservedAt": "2026-09-24T14:02:00Z",
"Facts": {
"deployment": "deploy-1842",
"deployment_status": "succeeded",
"previous_deployment": "deploy-1839",
"change_summary": "pricing-rule refresh and structured logging update",
"rollback_performed": "false"
}
},
{
"Id": "E-202",
"Type": "deployment",
"Service": "catalog-api",
"ObservedAt": "2026-09-23T19:40:00Z",
"Facts": {
"deployment": "deploy-991",
"deployment_status": "succeeded",
"rollback_performed": "false"
}
}
]
previous_deployment=deploy-1839, but there is no previous_deployment_completed_at. That missing field is intentional. The sample uses it as a regression test against plausible fabrication.Keep Guidance Separate from Evidence
A runbook tells engineers what to inspect. It does not prove that any of those conditions occurred. Give guidance its own type and store so that the distinction exists in code before the prompt ever sees it.
[
{
"Id": "RB-CHECKOUT-03",
"Service": "checkout-api",
"Title": "Checkout API elevated 5xx response",
"RecommendedChecks": [
"Compare error-rate onset with recent deployments.",
"Inspect database connection-pool utilization.",
"Check database connection counts before and after deployment.",
"Review traces for timeout or connection-acquisition failures.",
"Compare configuration with the previous known-good release."
],
"Caution": "These checks are diagnostic guidance. Their presence in this runbook does not establish root cause."
},
{
"Id": "RB-CATALOG-02",
"Service": "catalog-api",
"Title": "Catalog API degradation",
"RecommendedChecks": [
"Inspect downstream dependency latency.",
"Review cache hit rate.",
"Check database query duration."
],
"Caution": "These checks are diagnostic guidance. Their presence in this runbook does not establish root cause."
}
]
EvidenceStore RunbookStore ───────────── ──────────── What was observed What should be checked E-101 RB-CHECKOUT-03 E-201 diagnostic steps "database pool = 96%" "inspect pool utilization" FACT GUIDANCE
The same separation appears in the tool output through information_type=runbook_guidance and the caution text that explicitly says the checks do not establish root cause.
using System.Reflection;
using System.Text.Json;
namespace IncidentTriageAgent.Guidance;
/// <summary>
/// Loads deterministic runbook guidance from the
/// sample's embedded JSON data file.
/// </summary>
public sealed class RunbookStore
{
private static readonly JsonSerializerOptions JsonOptions =
new()
{
PropertyNameCaseInsensitive = true
};
private readonly IReadOnlyList<RunbookItem> _runbooks;
public RunbookStore()
{
_runbooks =
LoadRunbooks("runbooks.json");
}
/// <summary>
/// Returns the runbook for the requested service.
/// </summary>
public RunbookItem? GetForService(
string serviceName)
{
if (string.IsNullOrWhiteSpace(serviceName))
{
return null;
}
string service =
serviceName
.Trim()
.ToLowerInvariant();
return _runbooks
.FirstOrDefault(runbook =>
runbook.Service.Equals(
service,
StringComparison.OrdinalIgnoreCase));
}
private static IReadOnlyList<RunbookItem> LoadRunbooks(
string fileName)
{
Assembly assembly =
typeof(RunbookStore).Assembly;
string expectedSuffix =
$".Data.{fileName}";
string? resourceName =
assembly
.GetManifestResourceNames()
.SingleOrDefault(name =>
name.EndsWith(
expectedSuffix,
StringComparison.OrdinalIgnoreCase));
if (resourceName is null)
{
throw new InvalidOperationException(
$"Embedded runbook file '{fileName}' was not found.");
}
using Stream stream =
assembly.GetManifestResourceStream(
resourceName)
?? throw new InvalidOperationException(
$"Embedded runbook file '{fileName}' could not be opened.");
List<RunbookItem>? runbooks =
JsonSerializer.Deserialize<List<RunbookItem>>(
stream,
JsonOptions);
return runbooks
?? throw new InvalidOperationException(
$"Embedded runbook file '{fileName}' contained no valid data.");
}
}
Expose Read-Only Function Tools
Microsoft Agent Framework can expose an ordinary C# method as an AIFunction. Each tool in this sample is a thin adapter over a deterministic store.
Service health
using System.ComponentModel;
using IncidentTriageAgent.Evidence;
using Microsoft.Extensions.AI;
namespace IncidentTriageAgent.Tools;
/// <summary>
/// Read-only tool that exposes observed service-health evidence.
/// </summary>
public sealed class ServiceHealthTool
{
private readonly EvidenceStore _store;
public ServiceHealthTool(EvidenceStore store)
{
_store = store;
}
/// <summary>
/// Creates the Agent Framework function exposed to the model.
/// </summary>
public AIFunction CreateFunction()
{
return AIFunctionFactory.Create(
GetServiceHealth,
name: "get_service_health",
description:
"Returns observed read-only operational health evidence " +
"for a named service.");
}
[Description(
"Returns observed operational health evidence for a service.")]
public string GetServiceHealth(
[Description("Service name, for example checkout-api.")]
string serviceName)
{
EvidenceItem? evidence =
_store.GetLatest(
serviceName,
"service_health");
if (evidence is null)
{
return
$"NO-EVIDENCE | No service health record exists for '{serviceName}'.";
}
return $"""
{evidence.Id}
evidence_type={evidence.Type}
service={evidence.Service}
observed_at={evidence.ObservedAt:O}
status={evidence.GetFact("status")}
error_rate={evidence.GetFact("error_rate")}
error_spike_started={evidence.GetFact("error_spike_started")}
database_pool_utilization={evidence.GetFact("database_pool_utilization")}
""";
}
}
Recent deployment
using System.ComponentModel;
using IncidentTriageAgent.Evidence;
using Microsoft.Extensions.AI;
namespace IncidentTriageAgent.Tools;
/// <summary>
/// Read-only tool that exposes observed deployment evidence.
/// </summary>
public sealed class DeploymentTool
{
private readonly EvidenceStore _store;
public DeploymentTool(EvidenceStore store)
{
_store = store;
}
/// <summary>
/// Creates the Agent Framework function exposed to the model.
/// </summary>
public AIFunction CreateFunction()
{
return AIFunctionFactory.Create(
GetRecentDeployment,
name: "get_recent_deployment",
description:
"Returns observed read-only deployment evidence " +
"for a named service.");
}
[Description(
"Returns observed deployment evidence for a service.")]
public string GetRecentDeployment(
[Description("Service name, for example checkout-api.")]
string serviceName)
{
EvidenceItem? evidence =
_store.GetLatest(
serviceName,
"deployment");
if (evidence is null)
{
return
$"NO-EVIDENCE | No deployment record exists for '{serviceName}'.";
}
return $"""
{evidence.Id}
evidence_type={evidence.Type}
service={evidence.Service}
deployment={evidence.GetFact("deployment")}
deployment_status={evidence.GetFact("deployment_status")}
completed_at={evidence.ObservedAt:O}
previous_deployment={evidence.GetFact("previous_deployment")}
change_summary={evidence.GetFact("change_summary")}
rollback_performed={evidence.GetFact("rollback_performed")}
""";
}
}
Runbook guidance
using System.ComponentModel;
using IncidentTriageAgent.Guidance;
using Microsoft.Extensions.AI;
namespace IncidentTriageAgent.Tools;
/// <summary>
/// Read-only tool that exposes operational reference guidance.
///
/// Runbook content is guidance, not observed incident evidence.
/// </summary>
public sealed class RunbookTool
{
private readonly RunbookStore _store;
public RunbookTool(
RunbookStore store)
{
_store = store;
}
public AIFunction CreateFunction()
{
return AIFunctionFactory.Create(
GetRunbook,
name: "get_runbook",
description:
"Returns read-only operational runbook guidance for a named service. " +
"Runbook guidance describes recommended investigation steps; " +
"it is not evidence that a particular failure actually occurred.");
}
[Description(
"Returns operational runbook guidance for a service. " +
"Runbook content is reference guidance, not observed incident evidence.")]
public string GetRunbook(
[Description("Service name, for example checkout-api.")]
string serviceName)
{
RunbookItem? runbook =
_store.GetForService(serviceName);
if (runbook is null)
{
return
$"NO-GUIDANCE | No runbook exists for '{serviceName}'.";
}
List<string> lines =
[
runbook.Id,
"information_type=runbook_guidance",
$"service={runbook.Service}",
$"title={runbook.Title}",
"",
"recommended_checks:"
];
for (int i = 0;
i < runbook.RecommendedChecks.Length;
i++)
{
lines.Add(
$"{i + 1}. {runbook.RecommendedChecks[i]}");
}
lines.Add("");
lines.Add("caution:");
lines.Add(runbook.Caution);
return string.Join(
Environment.NewLine,
lines);
}
}
Build the Evidence-First Agent
The factory knows about the agent policy and tool composition, but it does not know how the model provider was configured. That provider-neutral boundary is the reason it receives IChatClient.
using IncidentTriageAgent.Evidence;
using IncidentTriageAgent.Guidance;
using IncidentTriageAgent.Tools;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace IncidentTriageAgent.Agents;
public sealed class TriageAgentFactory
{
private readonly EvidenceStore _evidenceStore;
private readonly RunbookStore _runbookStore;
public TriageAgentFactory(
EvidenceStore evidenceStore,
RunbookStore runbookStore)
{
_evidenceStore = evidenceStore;
_runbookStore = runbookStore;
}
public AIAgent Create(IChatClient chatClient)
{
ArgumentNullException.ThrowIfNull(chatClient);
ServiceHealthTool healthTool = new(_evidenceStore);
DeploymentTool deploymentTool = new(_evidenceStore);
RunbookTool runbookTool = new(_runbookStore);
List<AITool> tools =
[
healthTool.CreateFunction(),
deploymentTool.CreateFunction(),
runbookTool.CreateFunction()
];
return chatClient.AsAIAgent(
name: "IncidentTriageAgent",
instructions: AgentInstructions,
tools: tools);
}
// AgentInstructions contains the evidence-first rules shown below.
}
The system instructions then make the reasoning boundary explicit. These instructions do not mathematically guarantee perfect model behavior, but they make the intended contract inspectable and testable around the deterministic layers.
You are an evidence-first incident-triage agent.
1. Retrieve operational evidence before making factual claims.
2. Operational facts may come only from retrieved evidence.
3. Never invent or fill in a missing field.
4. Distinguish observed evidence, inference, guidance, unknowns,
and unproven conclusions.
5. Temporal correlation is not proof of causation.
6. A high metric value may justify investigation, but does not
by itself establish root cause.
7. Cite evidence IDs for operational facts.
8. Cite runbook IDs for guidance.
9. Say explicitly when evidence is insufficient.
10. You have read-only tools; never claim a write action occurred.
Observed fact
│
├── E-101: database_pool_utilization=96%
│
Reasonable inference
│
├── high utilization may justify investigating resource pressure
│
Unproven claim
│
└── "database pool pressure caused the outage" ← not establishedConfigure the Model Provider at the Edge
The live verification used OpenRouter's OpenAI-compatible endpoint, but that provider-specific configuration stays in Program.cs. Once an IChatClient exists, the agent factory no longer needs to know which endpoint or model produced it.
// -------------------------------------------------------------------
// Provider
// -------------------------------------------------------------------
//
// Provider-specific configuration stops here.
//
// The rest of the sample works with IChatClient.
// -------------------------------------------------------------------
OpenAIClientOptions clientOptions =
new()
{
Endpoint =
new Uri(
"https://openrouter.ai/api/v1")
};
OpenAIClient openAiClient =
new(
new ApiKeyCredential(apiKey),
clientOptions);
IChatClient chatClient =
openAiClient
.GetChatClient(modelName)
.AsIChatClient();
AsIChatClient(). The generic AsAIAgent() extension is then used by TriageAgentFactory against the IChatClient abstraction.The API key also stays outside source control. The application reads OPENROUTER_API_KEY from the environment and exits safely when it is absent.
Create an AgentSession and Stream Responses
Create one session for the incident, then reuse it for the follow-up. The session gives the second prompt conversational context without adding a separate persistent-memory subsystem.
AIAgent agent =
agentFactory.Create(
chatClient);
AgentSession session =
await agent.CreateSessionAsync();
The helper consumes AgentResponseUpdate values as they arrive:
static async Task StreamAgentResponseAsync(
AIAgent agent,
AgentSession session,
string prompt)
{
await foreach (
AgentResponseUpdate update
in agent.RunStreamingAsync(
prompt,
session))
{
Console.Write(update);
}
}
Build and Test Without an AI API Key
The deterministic layer should be verifiable in CI without paying for a model call or depending on a provider outage.
dotnet restore AgentFrameworkIncidentTriage.slnx
dotnet build AgentFrameworkIncidentTriage.slnx --configuration Release --no-restore
dotnet test AgentFrameworkIncidentTriage.slnx --configuration Release --no-build
The verified result was:
Two tests capture the design intent directly:
[Fact]
public void DeploymentEvidence_DoesNotInventMissingTimestamp()
{
EvidenceStore store = new();
EvidenceItem? evidence =
store.GetLatest(
"checkout-api",
"deployment");
Assert.NotNull(evidence);
Assert.Equal(
"deploy-1839",
evidence.GetFact("previous_deployment"));
Assert.Null(
evidence.GetFact(
"previous_deployment_completed_at"));
}
[Fact]
public void RunbookTool_ReturnsGuidanceNotEvidence()
{
RunbookStore store = new();
RunbookTool tool = new(store);
string result =
tool.GetRunbook("checkout-api");
Assert.Contains("RB-CHECKOUT-03", result);
Assert.Contains("information_type=runbook_guidance", result);
Assert.Contains("does not establish root cause", result);
}
The test suite also checks service-name normalization, unknown services, evidence IDs, tool output, and successful creation of all three AIFunction instances.
Run the Live Agent
Only the live path needs a provider key. Do not place the key in Program.cs, JSON, tests, or the repository.
PowerShell
$env:OPENROUTER_API_KEY="YOUR_TEMPORARY_KEY"
dotnet run --project src/IncidentTriageAgent
# Clear it when finished
$env:OPENROUTER_API_KEY=$null
Bash
export OPENROUTER_API_KEY="YOUR_TEMPORARY_KEY"
dotnet run --project src/IncidentTriageAgent
unset OPENROUTER_API_KEY
.env loading: this sample reads process environment variables directly. Creating a .env file by itself will not configure the application unless you add a loader.Do not treat exact model wording as a test oracle. The useful verification target is the information boundary: evidence IDs should be preserved, missing fields should remain unknown, guidance should remain guidance, and chronology should not become causation.
What the Verified Run Proved
The live run produced an evidence-structured assessment and then answered a same-session follow-up. The important part is the reasoning boundary, not the exact prose.
deploy-1842 completed successfully.| Question | Verified behavior |
|---|---|
Was deploy-1839 recognized? | Yes, as the previous deployment. |
Was a timestamp invented for deploy-1839? | No. The timestamp stayed UNKNOWN. |
Was RB-CHECKOUT-03 used? | Yes, as diagnostic guidance rather than incident evidence. |
| Was 96% database pool utilization treated as proof of root cause? | No. It was treated as an investigation signal. |
Did the follow-up claim deploy-1842 caused the outage? | No. The response explicitly separated correlation from causation. |
The Evidence-First Agent Contract
The project documents a small application-level contract for agents that reason over operational evidence. It is not an official Microsoft Agent Framework specification.
These rules are intentionally narrow. They do not solve authentication, prompt injection, malicious tools, persistent memory, compliance, or model evaluation. They solve one smaller problem: keeping retrieved facts, model interpretation, guidance, unknowns, and unproven claims visibly separate.
AI Agent Tool Risk Ladder
The second reference concept classifies tools by consequence, not by how easy they are to expose to the model. Again, this is an application-level design model, not an official Microsoft classification.
| Level | Capability | Example | This sample? |
|---|---|---|---|
| 0 | Reference | get_runbook | Yes |
| 1 | Observational | get_service_health | Yes |
| 2 | Reversible write | Create an incident note | No |
| 3 | Operational action | Restart a service | No |
| 4 | Destructive / high impact | Delete production data | No |
model recommends an action
≠
system authorizes the actionMicrosoft's current ChatClientAgent source also warns that model-selected function arguments should be treated as untrusted input and that tools with side effects, sensitive access, or irreversible consequences deserve explicit approval controls. That aligns naturally with the sample's decision to stop at Levels 0 and 1.
Production Considerations
The sample is intentionally reproducible, not production-complete. The architecture can grow without discarding its central boundary.
tutorial
Embedded JSON
│
▼
EvidenceStore
│
▼
read-only tools
│
▼
AIAgent
production evolution
Azure Monitor / App Insights / Datadog / database / deployment API
│
▼
adapter with authorization + validation
│
▼
same evidence-oriented tool boundary
│
▼
AIAgent- Replace stores, not reasoning categories. Live telemetry can replace JSON while still preserving evidence IDs and missing values.
- Authenticate and authorize every real data source. A read-only tool can still expose sensitive operational data.
- Treat tool arguments as untrusted. Validate service names, identifiers, scopes, and query ranges outside the model.
- Keep side effects behind stronger controls. Restart, rollback, deployment, deletion, customer messaging, and credential operations need policy and approval boundaries.
- Audit both retrieval and action. You should be able to reconstruct which source record supported a claim and which actor initiated an operation.
- Plan for prompt injection in external content. Logs, tickets, runbooks, and retrieved documents are data; do not blindly treat embedded instructions as trusted commands.
- Evaluate model behavior separately from deterministic tests. Tool output can be asserted exactly; natural-language interpretation usually needs scenario-based evaluation rather than string equality.
Common Problems and What They Mean
AsAIAgent is not found on the OpenAI SDK chat client
Do not assume the provider-specific concrete client is already the Agent Framework abstraction. In the verified sample the working path is:
OpenAIClient ↓ GetChatClient(model) ↓ AsIChatClient() ↓ IChatClient ↓ AsAIAgent(...)
401 Unauthorized when using an OpenRouter key
An OpenRouter key sent to the default OpenAI endpoint will fail. Configure the OpenAI-compatible endpoint as https://openrouter.ai/api/v1 before creating the chat client.
The application exits before creating the agent
That is expected when OPENROUTER_API_KEY is absent. The sample is designed to build and test without a secret.
An older example uses AgentThread
Check the package version. This verified baseline uses AgentSession. Agent APIs have evolved, so copying code without version context is risky.
Your live output does not match the captured sample word for word
That is normal. The deterministic records are stable; model phrasing is not. Check whether the same evidence boundaries hold instead of comparing strings.
The model still makes an inference you dislike
Prompts are not formal proofs. Strengthen deterministic checks where possible, add evaluation scenarios, reduce tool ambiguity, and require human review for consequential decisions.
Frequently Asked Questions
What is Microsoft Agent Framework?
It provides agent and workflow abstractions for building AI applications. In this tutorial the important .NET pieces are AIAgent, ChatClientAgent, IChatClient, AgentSession, function tools, and streaming.
What is AgentSession used for?
It represents conversation state for an agent. This sample creates one session for the incident assessment and reuses it for the follow-up question about whether deploy-1842 caused the outage.
Does CI need an AI API key?
No. The GitHub Actions workflow restores, builds, and runs all 14 deterministic tests without a live model provider key.
Can the agent restart services or roll back deployments?
No. All three tools are read-only. The agent may recommend an investigation step, but it has no tool capable of performing remediation.
Why use embedded JSON instead of Azure Monitor or another live system?
Fixed data makes the tutorial reproducible. Every reader gets the same timestamps, error rate, deployment, missing field, and runbook. A production implementation can replace the stores with live adapters.
Is the Evidence-First Agent Contract official Microsoft guidance?
No. It is a design pattern created for this educational reference sample. The tutorial labels it as application-level guidance so it is not confused with a Microsoft specification.
Can I replace OpenRouter?
Yes, when your provider can be exposed through IChatClient. Keep provider configuration at the application edge and let the agent factory depend on the abstraction.
Official Microsoft References
The Microsoft Agent Framework API details discussed in this tutorial are based on official Microsoft documentation, samples, and source code.
Verified dotnet-guide.com Companion Sample
The complete implementation used for this tutorial is maintained separately in the dotnet-guide.com GitHub tutorial repository. It includes the application source, deterministic evidence, runbook guidance, automated tests, architecture notes, verification record, and captured sample run.