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 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.

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.

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, 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) registers tool descriptors, runs the hosted-tool coverage audit, wraps every client-invoked AIFunction, and returns a new ChatOptions instance.
  • 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 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.

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 chain

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, 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.

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.

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.

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, WithAffiant throws, 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; they’re framework-generic and apply unchanged whether your host runs SK, MAF, or M.E.AI.