Microsoft.Extensions.AI and Agent Framework
Code: src/IntentSharp.Extensions.AI/ (package IntentSharp.Extensions.AI, namespace
IntentSharp.Extensions.AI). The package depends on Microsoft.Extensions.AI.Abstractions, on the core
library and on IntentSharp.Catalog. The core library does not depend on Microsoft.Extensions.AI.
Decision record: ADR 0012. Plan:
workplan-v4-behaviour-as-data.md.
Orchestrate with Agent Framework, decide and verify with IntentSharp
Microsoft Agent Framework orchestrates: it holds the conversation, chooses which tool to call next and runs multi-step and multi-agent workflows. IntentSharp decides and verifies: it applies one intent to one value and hands back a typed result only after deterministic checks pass, retrying with concrete findings when the model gets it wrong, with the intent's wording, rules and examples kept in versioned files. The two fit together rather than compete. Let the agent work out what to do next, and let IntentSharp make each business decision the agent delegates and prove that the answer meets the rules before the agent sees it.
They meet at two points, both in this package:
- The same model clients. Agent Framework runs on Microsoft.Extensions.AI's
IChatClient.UseChatClientruns IntentSharp on the sameIChatClient, so one configured backend (Azure OpenAI, OpenAI, Ollama, or anything else that has anIChatClient) serves both. - Intents as tools.
AsAIFunctionturns a catalog intent into a Microsoft.Extensions.AIAIFunction, the tool type agents call. The agent supplies the input; the intent runs with its shape, rules, validators and retries; the agent gets back verified JSON or an explicit failure.
Running IntentSharp on an IChatClient
using Microsoft.Extensions.AI;
using IntentSharp;
using IntentSharp.Extensions.AI;
static IntentClient Build(IChatClient primary, IChatClient backup) =>
new IntentClientBuilder()
.UseChatClient(primary, name: "Primary")
.UseChatClientAsFallback(backup, name: "Backup")
.Build();
| Member | Purpose |
|---|---|
ChatClientProvider(IChatClient, IEmbeddingGenerator? = null, string? name = null, string? model = null, ILogger? logger = null) |
An IAIProvider that wraps an IChatClient, and optionally an IEmbeddingGenerator<string, Embedding<float>>. The logger receives each translated failure at Debug. |
ChatClientProvider.ChatClient |
The wrapped chat client. |
UseChatClient(IChatClient, IEmbeddingGenerator? = null, string? name = null, string? model = null, ILogger? logger = null) |
On IntentClientBuilder: a ChatClientProvider as the primary provider. |
UseChatClientAsFallback(...) |
The same, as the fallback provider. |
ChatClientProvider is an ordinary IAIProvider, so it can also be passed to UseProvider or
UseFallbackProvider, or mixed with the built-in providers: an Anthropic primary with a chat-client
fallback, for example.
The provider does not take the logger factory given to IntentClientBuilder.WithLoggerFactory. Pass
logger to UseChatClient, UseChatClientAsFallback or the constructor to see the translated failures
(Failures and fallback); without one the provider logs nothing.
How a request is mapped
AIRequest |
Chat client |
|---|---|
SystemPrompt, when it is not empty |
the first ChatMessage, with ChatRole.System |
each message in Messages, in order |
a ChatMessage with that message's role: user, assistant, system and tool become the ChatRole of that name, any other role new ChatRole(role) |
Model, or DefaultModel when the request names none |
ChatOptions.ModelId, left unset when both are empty |
MaxOutputTokens |
ChatOptions.MaxOutputTokens |
Temperature |
ChatOptions.Temperature |
Format = JsonSchema with a JsonSchema |
ChatOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema(schema, name) |
Format = JsonSchema with no JsonSchema |
ChatOptions.ResponseFormat = ChatResponseFormat.Json |
Format = JsonObject |
ChatOptions.ResponseFormat = ChatResponseFormat.Json |
Format = Text |
no response format |
The pipeline's own requests carry one user message (a retry puts the previous attempt and its problems
into that message); a request built by other code with several messages, such as user, assistant, user,
reaches the chat client as that many messages with their own roles. Request tags are never sent. The call
is IChatClient.GetResponseAsync.
How a response is mapped
| Chat client | AIResponse |
|---|---|
ChatResponse.Text |
Content |
ChatResponse.ModelId |
Model |
ChatResponse.Usage (UsageDetails.InputTokenCount, OutputTokenCount) |
Usage |
ChatResponse.FinishReason == ChatFinishReason.Length |
IsTruncated = true |
ChatResponse.FinishReason == ChatFinishReason.ContentFilter |
IsRefusal = true |
The pipeline relies on IsTruncated and IsRefusal: a truncated reply always fails its attempt, and a
refusal ends the attempt loop (verification.md).
Name, model and embeddings
Nameis thenameyou pass. Without one it is the client's provider name fromGetService<ChatClientMetadata>()(ChatClientMetadata.ProviderName), and without that,"ChatClient". The name appears in results, rate limiting, cache keys and logs, and it is whatAIRequestOptions.Providerand an intent'sproviderrefer to.DefaultModelcomes from themodelargument or fromChatClientMetadata.DefaultModelId. When neither names a model it is empty, and a request that names no model leaves the choice to the chat client.SupportsEmbeddingsis true only when you pass anIEmbeddingGenerator<string, Embedding<float>>.IntentClient.EmbedAsyncuses the first provider that supports embeddings.
Give the primary and the fallback different names. The client keys its providers by name, ignoring case,
and IntentClientBuilder refuses a primary and a fallback with the same name with ArgumentException.
Two chat clients for one vendor usually report the same metadata name, so pass name to both calls.
Failures and fallback
ChatClientProvider does not retry; put retries in the chat client's own pipeline. It translates every
failure of the chat client or the embedding generator into an IntentSharp provider exception, with the
original exception as the inner exception, so the core's fallback and issue rules
(request-pipeline.md) apply to it as they do to the HTTP
providers:
| Chat-client failure | Becomes | Fallback allowed |
|---|---|---|
| an HTTP status on the exception or an inner exception: 401, 403 | AIAuthenticationException |
no |
| 429 | AIRateLimitException |
yes |
| 400, 422 | AIValidationException |
no |
| 408 | AIProviderException with IsTransient |
yes |
| 5xx | AIProviderInternalException |
yes |
| any other status | AIProviderException |
no |
an HttpRequestException without a status, or another transport error |
AIProviderException with IsTransient |
yes |
a cancellation the caller did not ask for (the HTTP timeout inside the client), or a TimeoutException |
AITimeoutException |
yes |
| any other exception | AIProviderException, not transient |
no |
no exception, but a null ChatResponse |
AIProviderException ("name chat client returned no response."), not transient |
no |
The status is read from HttpRequestException.StatusCode, or from a public Status or StatusCode
property of type int or HttpStatusCode, which is the shape of the OpenAI and Azure SDK exceptions; the
package depends on no vendor SDK. Two failures are not translated: an exception that is already an
IntentSharpException, and a cancellation of the caller's own token, which reaches the pipeline
unchanged.
Every translated exception keeps the exception the chat client threw as its InnerException, with its
stack trace and vendor properties; its message carries the original's type name and message, up to 2,000
characters. A provider with a logger also logs the original at Debug before it throws the translation.
AIRateLimitException.RetryAfter is always null, because the adapter reads no Retry-After value.
AIValidationException.HttpStatusCode is the status the chat client reported, 400 or 422 (as for the HTTP
providers), and the message names it too.
So a chat client's own failure ends the attempt as a Provider issue, or as a fallback, rather than escaping
TransformAsync as an exception.
What stays the same, and what does not
Everything in IntentClient runs unchanged on a chat client: the prompt and output contract, the
envelope and strict schemas, deterministic verification, retries with findings, the optional judge (which
uses the primary provider), memory, the request cache, the rate limiter, the license check, and the
catalog.
What differs from the built-in providers:
HttpProviderBase's retries and backoff do not apply. Transport resilience belongs to the chat client's own pipeline.ChatClientProvidermaps failures itself, close toHttpProviderBase's status mapping, with two differences: a 408 from a chat client is transient, and a 429 has noRetryAfter, because the adapter reads noRetry-Aftervalue (Failures and fallback).- Whether a backend enforces
ChatResponseFormat.ForJsonSchemadepends on the backend. Verification does not: a reply that is not JSON or does not match the envelope fails and is retried with the findings, whatever the backend did with the format.
Intents as tools
using Microsoft.Extensions.AI;
using IntentSharp.Catalog;
using IntentSharp.Extensions.AI;
static ChatOptions WithIntentTools(IntentRuntime intents) => new()
{
Tools = [.. intents.AsAIFunctions("Orders.ClassifyUrgency", "Invoice.Extract")]
};
Pass the functions to anything that accepts Microsoft.Extensions.AI tools: ChatOptions.Tools for a chat
client with function invocation, or the tool list of an Agent Framework agent.
| Member | Returns |
|---|---|
runtime.AsAIFunction("Name", toolName?, logger?) |
one AIFunction for that intent |
runtime.AsAIFunctions(params string[] names), runtime.AsAIFunctions(logger, params string[] names) |
one AIFunction per named intent, in the order given |
runtime.AsAIFunctions(), runtime.AsAIFunctions(logger) |
one AIFunction per enabled intent in the catalog, ordered by name |
The methods are extension methods in IntentToolExtensions on IntentRuntime directly, so no wrapper is
needed. The same overloads exist on IIntentInvoker, the interface for exposing some other intent runner
as tools. The logger (an ILogger) is optional; see below for what it receives.
Tool names are the intent names with every character outside A-Z a-z 0-9 _ - replaced by _, because
OpenAI and Anthropic refuse dots in tool names: Orders.ClassifyUrgency becomes Orders_ClassifyUrgency.
IntentToolExtensions.ToToolName does the conversion. A name longer than 64 characters
(MaxToolNameLength) throws ArgumentException instead of being cut; pass toolName to choose a shorter
one. AsAIFunctions(names) throws when two intents map to the same tool name. Each tool keeps the original
intent name in AITool.AdditionalProperties under IntentToolExtensions.IntentNameProperty
(intentsharp.intent).
Each function:
- is named as above and described from the definition's
description, its intent text, its result shape and the two replies below; - takes a single parameter,
input: a string, or an object forclrshapes; - runs the intent through
IntentRuntime.RunAsync, with its shape, rules, validators, examples, guardrails and audit tags; - answers a verified result with
{"success": true, "intent": "<intent name>", "value": <result>}; - answers everything else with
{"success": false, "intent": "<intent name>", "error": "..."}instead of throwing: a failed verification (the failure summary), a call with noinputargument, aninputargument that cannot be read as text, and any exception from the runtime (its type name and message). So a failure never breaks the agent loop. The agent sees what went wrong and can try again with a better input, ask the user, or move on.
Only a cancellation of the caller's own token propagates; a cancellation inside the runner, such as its own
timeout, is answered like any other exception. An exception answered with success: false never reaches
an operator through the agent loop, so a tool created with a logger logs each one at Warning, with the
exception. A failed verification is not logged by the tool; IntentRuntime records the run in its audit
line.
Each invocation goes through IntentRuntime.RunAsync, which reads the current definition, so an edited
intent file takes effect on the next tool call. The tool's name, description and input form are fixed when
the tool is created, and AsAIFunctions() lists the intents that are enabled when you call it; call it again
after a reload that changes descriptions or adds or re-enables intents.
AsAIFunction and AsAIFunctions(names) check only that each named intent exists (through
IntentRuntime.Describe), so a disabled intent named there still gets a tool, as does an intent disabled
after its tool was created. Each call of such a tool answers success: false with the
IntentDisabledException message and makes no model call. IntentRuntime refuses the run before it
starts, so it writes no audit line for it; the tool's Warning, when the tool has a logger, is the only
record.
Why a tool and not a free-form model call: the agent's model proposes, and the intent's rules and validators decide whether the answer stands. The check is deterministic, the failure is explicit, and every call is tagged with the intent's name, version and source (intent-catalog.md).
Testing
Tests never call a real model. Library tests use ScriptedProvider; the adapter's tests use a fake
IChatClient that records requests and returns scripted responses, and invoke the tools through
AIFunction.InvokeAsync.
Read next
- intent-catalog.md: intent files, rules, hot reload and the audit tags.
- providers.md:
IAIProviderand the built-in OpenAI and Anthropic providers. - ADR 0012: why
IChatClientis the boundary and the HTTP providers stay.