Documentation
Table of Contents

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:

  1. The same model clients. Agent Framework runs on Microsoft.Extensions.AI's IChatClient. UseChatClient runs IntentSharp on the same IChatClient, so one configured backend (Azure OpenAI, OpenAI, Ollama, or anything else that has an IChatClient) serves both.
  2. Intents as tools. AsAIFunction turns a catalog intent into a Microsoft.Extensions.AI AIFunction, 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

  • Name is the name you pass. Without one it is the client's provider name from GetService<ChatClientMetadata>() (ChatClientMetadata.ProviderName), and without that, "ChatClient". The name appears in results, rate limiting, cache keys and logs, and it is what AIRequestOptions.Provider and an intent's provider refer to.
  • DefaultModel comes from the model argument or from ChatClientMetadata.DefaultModelId. When neither names a model it is empty, and a request that names no model leaves the choice to the chat client.
  • SupportsEmbeddings is true only when you pass an IEmbeddingGenerator<string, Embedding<float>>. IntentClient.EmbedAsync uses 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.
  • ChatClientProvider maps failures itself, close to HttpProviderBase's status mapping, with two differences: a 408 from a chat client is transient, and a 429 has no RetryAfter, because the adapter reads no Retry-After value (Failures and fallback).
  • Whether a backend enforces ChatResponseFormat.ForJsonSchema depends 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 for clr shapes;
  • 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 no input argument, an input argument 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.

  • intent-catalog.md: intent files, rules, hot reload and the audit tags.
  • providers.md: IAIProvider and the built-in OpenAI and Anthropic providers.
  • ADR 0012: why IChatClient is the boundary and the HTTP providers stay.