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.
What M.E.AI is
Section titled “What M.E.AI is”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.
The adapter’s shape
Section titled “The adapter’s shape”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’sIChatClient, 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 onlyAffiant.SemanticKernel’s port completes through a backend-specific abstraction:IChatCompletionService, withFunctionChoiceBehavior.None(). WithAffiant(...)wrapping, onChatOptions. Unlike MAF’sAIAgent.WithAffiant, this bridge attaches toChatOptions— 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-invokedAIFunction, and returns a newChatOptionsinstance. 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 isAIFunctionFactory.Create’s, not the method’s: it strips a trailingAsyncfrom a method returningTask,ValueTaskorIAsyncEnumerable, so aTask<string> FetchThingAsync()isFetchThingboth to the model and on its descriptor, while a synchronousstring 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
AIFunctionis wrapped in anAffiantDelegatingAIFunction— aMicrosoft.Extensions.AIDelegatingAIFunction— whose invocation runs the whole neutral filter onion around the real tool body, reading the per-callFunctionInvokingChatClient.CurrentContextfor iteration, message history, and conversation id. This is deliberately not theFunctionInvokingChatClient.FunctionInvokerdelegate, 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 callingAIFunction.InvokeAsyncdirectly.
A wiring example
Section titled “A wiring example”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 typebuilder.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 chainThe turn scope
Section titled “The turn scope”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.
Graceful degradation on inference failure
Section titled “Graceful degradation on inference failure”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.
One Affiant adapter per tool catalog
Section titled “One Affiant adapter per tool catalog”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 adapter — Affiant.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.
The hosted-tool boundary
Section titled “The hosted-tool boundary”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,
WithAffiantthrowsAffiantCoverageException(as of1.0.0-beta.3, via the framework’s ownToolCoverage.Refuse— previously a plainInvalidOperationException), 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.
Where this fits
Section titled “Where this fits”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.