Skip to content

Using Affiant with Microsoft.Extensions.AI

Status: the Microsoft.Extensions.AI (M.E.AI) bridge described on this page is merged to main in the Sakwala/affiant repository — a first-class, fully-tested part of the ten-package set, currently at 1.0.0-beta.3, alongside Affiant.SemanticKernel and Affiant.AgentFramework. The wiring shown below — AddAffiantExtensionsAI(), AffiantToolCatalog, ChatOptions.WithAffiant(...) — is unchanged by 1.0.0-beta.3; most of that release’s breaking changes land in Affiant.Core and Affiant.Abstractions (the review-decision and Docket-row contracts — see Review Gate & Write Executors and Docket & Evidence Cards), not in this adapter’s own attach points. Two things here did change. First, this adapter’s hosted-tool coverage audit now refuses through the framework’s own ToolCoverage.Refuse, raising AffiantCoverageException (a subclass of the new AffiantRefusalException) in place of the plain InvalidOperationException it used to throw — a host catching the old type around WithAffiant should add the new one rather than swap to it: WithAffiant still throws a plain InvalidOperationException on its other refusal paths — an unregistered IAffiantToolRegistry, ToolInvocationPipeline or ExtensionsAIOptions, and the double-wrap guard. Second, ExtensionsAIInferenceCompletionPort — the inference completion port this adapter owns — gained a third, optional TimeProvider? constructor parameter, so the today’s-date line of its inference prompt reads a clock a test can move. That is source-compatible and a declared binary break against 1.0.0-beta.1.1, recorded in the package’s own CompatibilitySuppressions.xml: a host that constructs the port itself needs a recompile, and a host that lets the container build it changes nothing. See Packages for the full package set and Interception Backends for how this bridge compares to the other two.

HR Portal, the public demo at hrportal.affiant.dev, runs on this bridge.

Microsoft.Extensions.AI is the lower-level abstraction both the Microsoft Agent Framework and Semantic Kernel are built on — Microsoft.SemanticKernel.Abstractions 1.74.0 takes a package dependency on it, and SK’s own KernelFunction derives from Microsoft.Extensions.AI.AIFunction, though SK’s chat abstraction (IChatCompletionService) and auto-invocation loop are its own. What this package gives you is IChatClient, ChatMessage/AIContent, and AIFunction for tool calling, with no agent-framework concepts (agents, sessions, orchestration) layered on. If your host talks to IChatClient directly and doesn’t want an agent-framework dependency at all, this is the backend to pick.

Affiant’s stance: this is not a “simpler, less capable” backend — it’s the same neutral pipeline, the same provenance tagging, the same review gate, attached at a lower seam. Choosing it is an architectural preference (no agent-framework dependency), not a feature trade-off.

Affiant.Extensions.AI is Affiant’s third interception backend, a peer of Affiant.SemanticKernel and Affiant.AgentFramework behind the same shared, backend-neutral pipeline in Affiant.Core. Provenance tagging, review gating and the machinery around task inference are the same Affiant.Core code on all three; what differs is how the framework attaches, and the narrow behaviours that follow from it — which Interception Backends and the FAQ set side by side:

  • Neutral pipeline. Interception, provenance tagging, task-inference orchestration and review-gating are defined once, backend-neutrally, in Affiant.Core; this bridge contains no tagging, inference-orchestration, or review-gate logic of its own. What it does own besides the attach point is an inference completion port: it builds the structured-output prompt itself and completes it against the host’s IChatClient, passing no options at all so the inference call has no tool list to route to. Affiant.AgentFramework’s port is the same code over the same abstraction — this one is a copy of it — and only Affiant.SemanticKernel’s port completes through a backend-specific abstraction: IChatCompletionService, with FunctionChoiceBehavior.None().
  • WithAffiant(...) wrapping, on ChatOptions. Unlike MAF’s AIAgent.WithAffiant, this bridge attaches to ChatOptions — the counterpart entry point, and the one place a host’s tool list lives at this seam. chatOptions.WithAffiant(serviceProvider, catalog) runs the wire-up double-wrap guard, then the hosted-tool coverage audit, then registers the catalog’s tool descriptors, wraps every client-invoked AIFunction, and returns a new ChatOptions instance. The audit runs before any registry mutation deliberately: a refused wiring leaves the singleton tool registry untouched, so a corrected retry does not die with “already registered”.
  • AffiantToolCatalog, same shape as MAF. No [KernelFunction]-equivalent marker attribute — AffiantToolCatalog.FromType<T>() reflects over every public instance method on your tool type in one pass, skipping only property and event accessors, object’s own members and generic method definitions, and throwing on an overloaded method name. The name the model sees is AIFunctionFactory.Create’s, not the method’s: it strips a trailing Async from a method returning Task, ValueTask or IAsyncEnumerable, so a Task<string> FetchThingAsync() is FetchThing both to the model and on its descriptor, while a synchronous string FooAsync() keeps its literal name — the same return-type rule MAF’s catalog follows, which this one is a copy of. [AffiantToolName("…")] overrides the name, and two methods that end up with the same visible name are refused like an overload. Keep a tool type’s public surface limited to tool methods.
  • Interception by being the tool, not by a side-channel delegate. Each AIFunction is wrapped in an AffiantDelegatingAIFunction — a Microsoft.Extensions.AI DelegatingAIFunction — whose invocation runs the whole neutral filter onion around the real tool body, reading the per-call FunctionInvokingChatClient.CurrentContext for iteration, message history, and conversation id. This is deliberately not the FunctionInvokingChatClient.FunctionInvoker delegate, which is last-write-wins and silently no-ops if a host forgets to configure it — a wrapper cannot be bypassed, even by a custom loop calling AIFunction.InvokeAsync directly.

Steps 1, 2 and 4 mirror the real wiring in Affiant.Extensions.AI (verified against src/Affiant.Extensions.AI/Extensions/ChatOptionsExtensions.cs and src/Affiant.Extensions.AI/Extensions/ServiceCollectionExtensions.cs in the framework repository, and the package’s own README). Four details in the block are this guide’s own rather than the README’s. Step 3 is the largest: the README’s quickstart builds the chat client once from the application provider, with no per-turn scope — the shape “The turn scope” below is about. Step 1’s IChatClient and WorkOrderTools registrations are not in that quickstart either, though the port the adapter registers takes an IChatClient in its constructor and AffiantToolCatalog resolves the tool type from the invocation’s own provider on every call. And step 4’s template-and-Clone() pattern follows from wiring ChatOptions once, where the README sets ConversationId straight onto the wired options.

using Affiant.Core.Extensions;
using Affiant.Extensions.AI;
using Affiant.Extensions.AI.Extensions;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
// 1. Wire-up, once: the neutral pipeline, this backend's bridge, your tool type, and the provider
// client the adapter's inference port completes against. AddAffiantExtensionsAI registers that
// port, and the port's constructor takes IChatClient, so the last registration is required,
// not a convenience.
builder.Services.AddAffiantCore();
builder.Services.AddAffiantExtensionsAI();
builder.Services.AddScoped<WorkOrderTools>(); // your tool type
builder.Services.AddSingleton<IChatClient>(providerClient); // your provider client
// 2. Also once, while the host is starting and before it serves a turn: build the catalog and
// wire ChatOptions. WithAffiant registers every descriptor with the singleton tool registry,
// and that registry refuses a duplicate — so calling WithAffiant per turn succeeds on turn 1
// and throws on turn 2. Keep the ChatOptions it returns as the template every turn clones.
var catalog = AffiantToolCatalog.FromType<WorkOrderTools>();
ChatOptions template = new ChatOptions { Tools = [.. catalog.Functions] }
.WithAffiant(serviceProvider, catalog);
// 3. Per turn: open a scope, and build the function-invoking client FROM THAT SCOPE.
// UseFunctionInvocation() is the client that runs the tool loop and publishes the per-call
// FunctionInvokingChatClient.CurrentContext Affiant's wrapper reads; the provider it was built
// from is the provider every tool call resolves against. See "The turn scope" below.
using var turn = serviceProvider.CreateScope();
IChatClient client = new ChatClientBuilder(providerClient)
.UseFunctionInvocation()
.Build(turn.ServiceProvider);
// 4. Required, not optional — see "Set ConversationId" below.
var chatOptions = template.Clone();
chatOptions.ConversationId = conversationId;
var response = await client.GetResponseAsync(messages, chatOptions);

Like MAF’s AddAffiantAgentFramework(), AddAffiantExtensionsAI() is a one-call analog of SK’s AddAffiantSemanticKernel() + AddAffiantInferenceOrchestration() combined — M.E.AI has a single function-calling seam, not SK’s invocation/auto-invocation split, so there’s no separate pre-tool/post-tool registration to keep apart.

What the block leaves out. These are the adapter’s attach points, not a complete host. AddAffiantCore() installs AffiantWireUpValidator, which refuses the application at startup with AffiantStartupException when IStreamingTransport or IDocketStore is unregistered; when any registered tool declares an update-shaped operation and no IPreviousValueSource is registered; and, once any tool is declared write-capable, when IReviewContextProvider, ReviewGate or IDecisionAuthorizationPolicy is missing too, since without a decision policy the gate falls back to DenyAllDecisionAuthorization and refuses every decision. A registered IApprovalPolicy that reports a configuration fault — a risk threshold with no scorer wired — is refused alongside those, and an AffiantCoreOptions.DefaultDocketTtl under a millisecond, or large enough to overflow the stamp, is refused before any container question is asked at all. AffiantCoreOptions.AcknowledgeMissingReviewWiring downgrades the refusal to one startup warning per contract, for a host that deliberately runs the read and inference half with no review loop. What suppresses that acknowledgment is the review-wiring list being non-empty, not the mere presence of a write tool: declare a write tool and leave IReviewContextProvider, ReviewGate or IDecisionAuthorizationPolicy unregistered — or ship a faulty policy — and startup refuses whatever the flag says, while a host that registers all three still gets the downgrade for a missing IStreamingTransport, IDocketStore or IPreviousValueSource. A container that does not provide IServiceProviderIsService skips all of that — the registration questions and the policy-configuration check alike — with a debug log. A container that also validates on build — the .NET host’s default in Development — needs an IStreamingTransport and an IDocketStore, for the ReviewGate that AddAffiantCore() registers; an IRouteRegistry, for the UiGuidanceBridge it registers beside it; and an ITaskInferenceStrategy, for the affidavit projection AddAffiantExtensionsAI() registers when it finds no IAffidavitProjection already registered — a host that registers a projection of its own before that call needs no strategy contract. AddAffiantTool<TStrategy> registers the concrete strategy type, not the contract, and the guard is evaluated when AddAffiantExtensionsAI() runs, so registering one afterwards is too late. The validator reads the tool registry that step 2 fills, so wire the catalog after Build() and before the host starts serving; wired later, its write-tool checks look at an empty registry.

Wrapping produces a new ChatOptions instance. WithAffiant returns a clone with the wired tool list; it never mutates the object you pass in. A pre-wrap chatOptions local (or whatever object you built before calling WithAffiant) that anything in your codebase still uses instead of the returned value silently bypasses Affiant entirely:

// Wrong — the unwrapped `chatOptions` local is still usable and bypasses Affiant entirely.
var chatOptions = new ChatOptions { Tools = [.. catalog.Functions] };
var wired = chatOptions.WithAffiant(sp, catalog);
await client.GetResponseAsync(messages, chatOptions); // BUG: no provenance captured, no review gate
// Right — only the wired instance exists in scope past the wiring line.
var chatOptions = new ChatOptions { Tools = [.. catalog.Functions] }
.WithAffiant(sp, catalog);
chatOptions.ConversationId = conversationId;
await client.GetResponseAsync(messages, chatOptions); // Affiant's wrapper is in the call chain

FunctionInvokingChatClient puts the provider the ChatClientBuilder was built from onto every tool call’s AIFunctionArguments.Services, and that is the provider Affiant’s wrapper hands the neutral pipeline as its ambient provider. Build the client from the application root — the wiring the adapter’s own README quickstart shows — and the pipeline is asked to resolve its scoped filters, and the scoped ContextFabric behind them, from the root provider. Under scope validation, which the .NET host turns on by default in Development, that throws InvalidOperationException: Cannot resolve scoped service … from root provider inside the wrapper; FunctionInvokingChatClient records the throw as a failed tool call and lets the loop continue, so the model answers without the tool and Affiant governs nothing. With validation off nothing throws: one ContextFabric is then shared by every conversation in the process, which is the silent half of the same mistake and what the next section is about.

Building the client from a per-turn scope, as in step 3 above, removes both. It is also what a tool type with scoped dependencies needs: AffiantToolCatalog resolves your tool type from the invocation’s own provider on every call, so an AddScoped<WorkOrderTools>() registration is correct only when that provider is a scope — from the root it is a captive instance shared process-wide, or a refusal under scope validation. HR Portal, the host behind the public demo, builds its chat client per turn for exactly this reason.

Set ConversationId — omitting it silently degrades inference

Section titled “Set ConversationId — omitting it silently degrades inference”

This is the sharpest edge on this backend specifically, and it’s easy to hit without any error telling you. Affiant runs task inference once per (conversation, tool, turn). When ChatOptions.ConversationId is null or empty, there is no conversation to key on, so InferenceTriggerFilter falls back to the identity hash of the ambient IContextFabric instance — a conservative fallback worth exactly as much as that instance’s lifetime, and one all three backends share.

Pair that fallback with a chat client built from the application root, and the fabric is process-global: every conversation collapses onto the same key, and the second and every later conversation silently skips write-tool inference — no exception, no warning, just Affidavits built from raw tool arguments with nothing inferred. The arguments ToolArgumentCaptureFilter captures bleed the same way: it upserts them onto one entity keyed by the tool’s declared entity type, with the bare argument names as that entity’s fields, so on a shared fabric one conversation’s values overlay another’s.

Both halves of the fix are in the wiring example above and cost a line each: build the client from a per-turn scope, and set ConversationId per conversation.

Setting it on ChatOptions covers the first round of a turn. FunctionInvokingChatClient re-derives the in-flight options’ ConversationId from each response before it runs the next round — the behaviour of Microsoft.Extensions.AI 10.9.0, the version this adapter pins — so against a stateless provider, one whose responses carry no conversation id of their own, every tool call from round two of a multi-round turn reads null and takes the fabric fallback again. The host’s own ChatOptions object is left untouched; it is the copy in flight that changes. What that costs depends on the scope: with the per-turn client of step 3 the fallback fabric belongs to this turn, so those later rounds key on the turn rather than on the conversation; with a client built from the application root it is the process-global fabric again, and round-two calls in different conversations collide on it exactly as they would if the id had never been set. Closing that gap is the host’s job at 1.0.0-beta.3: HR Portal puts a small delegating IChatClient inside UseFunctionInvocation() that restores the id on the options it is handed each round.

Each of the three bridges hands the pipeline the provider it has at hand — Semantic Kernel the Kernel’s own services, MAF and this bridge the AIFunctionArguments.Services the invoking client supplies — and that is where the three part company. MAF and this bridge are pinned to the application root in their documented wirings, because the provider is the invoking client’s to supply and it supplies the one it was built from. Semantic Kernel’s documented wiring is not: the framework’s tool-authoring guide tells a host to resolve the Kernel from the per-request scope, and the shipped quickstart injects it into a SignalR hub, which gets one DI scope per invocation — so that host has a per-turn fabric without doing anything further. An SK host that resolves a root Kernel shares the defect. A framework-owned per-turn scope is not part of 1.0.0-beta.3.

If the structured-output completion behind task inference fails — a provider outage, a malformed response, anything that is not a cancellation — TaskInferenceRunner catches it, emits an inference.failed telemetry event carrying affiant.error.kind (json_parse or provider_outage), logs a warning, and returns an empty result; InferenceTriggerFilter then lets the tool call proceed. This is Rule 5 (graceful degradation on provider failure), applied to the inference step, and it’s identical across all three backends.

What the reader of the Affidavit sees is degraded confidence, and nothing else. The fields inference would have filled are sworn Empty at confidence 0, and aggregate confidence is the minimum over every proposed field, so a failed inference pulls it to 0. No warning is written onto the Affidavit: Affidavit.Warnings carries the business-rule warnings the host passes to the projection plus the sentences the review gate appends at filing, and nothing in the inference path adds to it. The failure is loud in logs and telemetry and invisible on the Evidence Card.

A timeout is not covered by any of that. There is no timeout in the inference path at all, and a provider or HTTP client timeout arrives as a TaskCanceledException — an OperationCanceledException — which the completion port, TaskInferenceRunner and InferenceTriggerFilter each rethrow rather than absorb. It escapes Affiant’s wrapper as any other exception does, and FunctionInvokingChatClient handles it as it handles any other: it records a failed tool call and lets the loop continue, so the model answers without the tool — the same outcome the section above describes for an InvalidOperationException escaping the same wrapper. The loss is larger than a caught inference failure, though: inference runs before the tool body, so the tool never executes and no write proposal is filed at all. The contrast to hold on to is between a ConversationId mistake, which degrades inference silently and permanently for a conversation, and a transient provider failure, which costs at most the one call and says so only in telemetry.

Running Affiant’s neutral pipeline twice for one logical tool call is a wiring mistake. The package’s own README calls the pipeline not idempotent and names three costs — double-tagged provenance, task inference fired twice, the same write proposal filed onto the Docket twice. At 1.0.0-beta.3 the code is narrower than that description, and no easier to debug; what a second run really does is below the table. Never wire both Affiant.Extensions.AI and Affiant.AgentFramework over the same tool catalog or chat-client pipeline. Two guards catch the shapes this adapter can see:

Guard When Catches Misses
Wire-up marker WithAffiant, before anything is registered An Affiant wrapper anywhere in the tool list it is handed — sitting on ChatOptions.Tools, or carried in a catalog that was wired at another site and reused here A wrapper hidden behind another DelegatingAIFunction — host middleware, or MAF’s own per-run wrapper
Invoke-time re-entrancy guard First nested tool invocation A second Affiant.Extensions.AI wrapper around one logical tool call, at any depth and whatever hides it A second adapterAffiant.AgentFramework’s middleware records nothing this guard reads. It also fails the call rather than the wire-up

The cross-adapter case (this bridge plus Affiant.AgentFramework over the same tools) is caught by neither guard at 1.0.0-beta.3. The wire-up marker check is a top-level type test over the tool list it is handed, and MAF attaches its own function-invocation middleware to the agent rather than to that list. The invoke-time guard reads an AsyncLocal declared inside AffiantDelegatingAIFunction and written only by it, so MAF’s middleware, which runs the same neutral pipeline and then invokes the wrapped AIFunction, publishes nothing for it to read: both onions run.

What that costs is not the README’s three items. Two Docket rows are unreachable — the entry id is derived from the proposal and the context it is filed in (GT-4), so a second filing replays the first row rather than adding one. Task inference does not fire twice either, as long as both onions resolve the same ContextFabric, which the documented wirings do: the idempotency key is (conversation, tool, turn) anchored on that fabric instance, and the second run reads it as already seen. Provenance is not duplicated: ToolArgumentCaptureFilter mints no tag for an argument at all, and the fabric’s Upsert overlays an entity’s fields rather than appending them. What a declared write tool does produce is a card plus a contradiction — the inner gate files the proposal and ends the turn with its “filed for review” message, and the outer gate then deserializes that message, finds no write proposal in it, and refuses the call wireup-invalid, naming the tool’s result where the fault is the wiring. That is the review path, the one where a person still has to act. When the inner filing settles instead — a Standing Order’s approval, a ReferralRequired escalation, a replay of a row already settled — the gate hands back Decided and the inner filter deliberately leaves the tool’s own result alone, so what the outer gate reads is the proposal itself: it derives the same entry id, finds the row already there, and replays that decision instead of refusing. Either way one row, and either way the second run is wasted work. One adapter per tool catalog is therefore a rule the host keeps, not one the framework enforces across adapters. Within this adapter the guards do hold, and the fix when you hit one is always the same: call WithAffiant exactly once, on the unwrapped catalog, and use only the ChatOptions it returns.

A tool body that starts its own governed sub-agent is not double-wrapping and is explicitly allowed — that sub-agent’s FunctionInvokingChatClient publishes its own invocation context, so its tools run their own onion normally.

Stated plainly: Affiant on M.E.AI swears only to writes made by locally-invoked AIFunctions. This is the same boundary Affiant draws on SK and MAF — see The Honest Boundary for the full shape of it — reproduced a third time at this seam, not removed.

Tool kind Covered?
AIFunction (client-invoked) Yes — wrapped, fully gated
HostedWebSearchTool, HostedCodeInterpreterTool, HostedFileSearchTool, HostedMcpServerTool, HostedImageGenerationTool, HostedToolSearchTool No — provider-executed markers with no client-side invocation to wrap

WithAffiant(...) makes this structural rather than a silent gap, auditing the tool list before any turn runs. The audit’s test is structural too, rather than a list of known names: every tool on ChatOptions.Tools that is not an AIFunction is uncovered, so the six above are what a host usually meets, not the whole set the audit refuses.

  • Default: refuse. If the tool list contains any unacknowledged hosted/provider-side tool, WithAffiant throws AffiantCoverageException (as of 1.0.0-beta.3, via the framework’s own ToolCoverage.Refuse — previously a plain InvalidOperationException), naming every uncovered tool.

  • Override: explicit acknowledgment. ExtensionsAIOptions.AcknowledgeUncoveredTools = ["code_interpreter", ...] permits named hosted tools to pass through. Each acknowledgment emits a telemetry span and a logged warning at wrap time, so it’s auditable, never silent:

    builder.Services.AddAffiantExtensionsAI(options =>
    {
    options.AcknowledgeUncoveredTools = ["code_interpreter"];
    });

Unlike Affiant.AgentFramework’s AgentFrameworkOptions, this adapter’s options type has no AllowUnauditableAgent escape hatch — MAF needs one because it hides ChatOptions behind an opaque AIAgent whose tool set isn’t always enumerable before the first run; this bridge has no such opacity, since the host constructs ChatOptions itself and hands it to WithAffiant directly, so the tool list is always fully enumerable.

The Honest Boundary covers the hosted-tool limit in full. Interception Backends compares all three bridges side by side. Using Affiant with Microsoft Agent Framework covers the sibling MAF bridge — the two share the AffiantToolCatalog shape and the double-wrap concerns above. Packages has the full ten-package dependency graph. Authoring Write Tools and Authoring Read Tools cover the tool-authoring patterns this guide assumes. Their worked examples are written in the Semantic Kernel shape — [KernelFunction], Plugins.AddFromType, AffiantStartupValidator — and none of those exists on this bridge, where AffiantToolCatalog.FromType<T>() reflects over the public instance methods with no marker attribute, [AffiantToolName] overrides the LLM-visible name, and the startup check is AffiantWireUpValidator. What carries across unchanged is what those guides are really about: the dual-audience tool-return contract, [AffiantWriteTool], ContextExtractor, IWriteExecutor and WriteProposal.