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 1.0.0-beta.1 set, alongside
Affiant.SemanticKernel and Affiant.AgentFramework. Everything below documents real, tested
code, verified against the framework source and the Affiant.Extensions.AI package README at
commit 055a2b1. 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 Semantic Kernel and Microsoft Agent
Framework sit on top of — 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. The provenance tagging, task inference, and review-gating behavior
is identical across all three — only how the framework attaches differs:
- Neutral pipeline. All interception logic is defined once, backend-neutrally; this bridge contains no tagging, inference, or review-gate logic of its own.
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)registers tool descriptors, runs the hosted-tool coverage audit, wraps every client-invokedAIFunction, and returns a newChatOptionsinstance.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. 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”This mirrors 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):
using Affiant.Extensions.AI;using Affiant.Extensions.AI.Extensions;using Affiant.Core.Extensions;using Microsoft.Extensions.AI;
// 1. Register the neutral pipeline and this backend's bridge.builder.Services.AddAffiantCore();builder.Services.AddAffiantExtensionsAI();builder.Services.AddScoped<WorkOrderTools>(); // your tool type
// 2. The chat client must have UseFunctionInvocation() — that is the client that runs the tool// loop and publishes the per-call FunctionInvokingChatClient.CurrentContext Affiant's// wrapper reads.IChatClient client = new ChatClientBuilder(innerClient) .UseFunctionInvocation() .Build(serviceProvider);
// 3. Build the catalog and wire ChatOptions.var catalog = AffiantToolCatalog.FromType<WorkOrderTools>();
var chatOptions = new ChatOptions { Tools = [.. catalog.Functions] } .WithAffiant(serviceProvider, catalog);
// 4. Required, not optional — see "Set ConversationId" below.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.
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 chainSet 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, there is no conversation to key on, so the idempotency
key falls back to the identity of the conversation-state object (IContextFabric) instead — and
at this seam, that object is process-global: FunctionInvokingChatClient hands Affiant the
provider the ChatClientBuilder was built from (your application root), not a per-conversation
scope. Every conversation therefore 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.
Setting ConversationId per conversation, as in the wiring example above, restores correct
behavior and costs one line. This limitation is shared by the Affiant.SemanticKernel and
Affiant.AgentFramework bridges too — all three source their ambient provider the same way —
and the framework-level fix (a per-turn scope) is tracked separately. Until then, on this
backend specifically, set ConversationId.
Graceful degradation on inference failure
Section titled “Graceful degradation on inference failure”If the structured-output completion behind task inference errors or times out, TaskInferenceRunner
catches everything except a genuine cancellation, logs a warning, and lets the tool call proceed
anyway — degraded confidence and a warning on the resulting Affidavit, never a broken turn. This
is Rule 5 (graceful degradation on provider failure), applied to
the inference step, and it’s identical across all three backends: a ConversationId mistake
degrades inference silently and permanently for a conversation, while a transient provider
failure degrades it loudly (a warning on the Affidavit) and only for that one call.
One Affiant adapter per tool catalog
Section titled “One Affiant adapter per tool catalog”Affiant’s neutral pipeline is not idempotent — running it twice for one logical tool call
double-tags provenance, fires task inference twice, and files the same write proposal onto the
Docket twice, a silent semantic corruption rather than an error. Never wire both
Affiant.Extensions.AI and Affiant.AgentFramework over the same tool catalog or chat-client
pipeline. Two guards enforce this:
| Guard | When | Catches | Misses |
|---|---|---|---|
| Wire-up marker | WithAffiant, before anything is registered |
An Affiant wrapper sitting directly on ChatOptions.Tools |
A wrapper hidden behind another DelegatingAIFunction — host middleware, or MAF’s own per-run wrapper |
| Invoke-time re-entrancy guard | First nested tool invocation | Every nesting shape, at any depth, including the cross-adapter case | Nothing in this class — but it fails the call rather than the wire-up |
The cross-adapter case (this bridge plus Affiant.AgentFramework over the same tools) cannot be
caught by the wire-up guard: MAF rewrites ChatOptions.Tools with its own private wrapper type
after this adapter’s wire-up has already run, and that type carries no marker either package can
see. The invoke-time guard is the backstop. If you hit it, the fix 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:
-
Default: refuse. If the tool list contains any unacknowledged hosted/provider-side tool,
WithAffiantthrows, 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; they’re framework-generic and apply unchanged whether your host runs SK, MAF, or
M.E.AI.