Skip to content

Interception Backends

Affiant has three interception backends — Semantic Kernel (SK), Microsoft Agent Framework (MAF), and Microsoft.Extensions.AI (M.E.AI) — sitting on one shared, backend-neutral pipeline in Affiant.Core. Provenance tagging, review gating, and the machinery around task inference — the trigger, the merge, and the once-per-(conversation, tool, turn) idempotency rule (InferenceTriggerFilter, TaskInferenceRunner, TaskInferenceMergeFilter and the TaskInferenceStep it delegates to, all in Affiant.Core) — are defined once and run the same way on all three. What differs is where the key’s inputs come from: the turn component is read from kernel.Data["AffiantTurnNumber"] on SK and from the invoking client’s iteration counter on MAF and M.E.AI, which makes the deduplication there per round of a turn rather than per turn. Each backend package (Affiant.SemanticKernel, Affiant.AgentFramework, Affiant.Extensions.AI) is a thin bridge over that backend’s own tool-invocation seam, carrying no tagging and no review-gate logic of its own. The one piece of inference a bridge does own is its IInferenceCompletionPort: each builds the structured-output prompt itself and issues the completion with no tool routing, so the inference pass can never fire a tool. Only SK’s port completes through a Semantic Kernel abstraction — IChatCompletionService, with FunctionChoiceBehavior.None(); the MAF and M.E.AI ports both complete on Microsoft.Extensions.AI’s IChatClient and pass no options at all, so the call has no tool list to route to.

If your host… Use
Already runs on Semantic Kernel Affiant.SemanticKernel — the original, reference backend; every worked example in Quickstart uses it.
Is starting fresh, or wants Microsoft’s current-generation agent SDK Affiant.AgentFramework — MAF reached 1.0 GA on 2026-04-03 and is where Microsoft’s new feature investment goes.
Talks to IChatClient directly and doesn’t want an agent-framework dependency at all Affiant.Extensions.AI — the abstraction both the Agent Framework and Semantic Kernel build on; SK’s chat abstraction (IChatCompletionService) and its auto-invocation loop are its own.

All three are fully supported; none is deprecated or experimental. SK remains a safe, first-class choice — see the FAQ for Microsoft’s own SK support timeline. If you’re not sure, the deciding question is simply which orchestration layer, if any, your host already has: an existing Kernel, an existing AIAgent, or neither.

One Affiant bridge per tool catalog. Never wire more than one of the three over the same tool catalog or chat-client pipeline: the neutral pipeline is not idempotent, so a second wiring runs the whole onion again for one logical tool call. The framework’s own refusal message describes that as double-tagged provenance, a second task-inference pass, and the same write proposal filed on the Docket twice. The Docket half of that does not happen: an entry id is derived from the proposal rather than invented (GT-4), so a second filing of the same call finds the first row and replays it — “an idempotent replay, never a second entry”. What the second gate does with a declared write tool depends on how the first filing settled. On the review path, where a human still has to decide, the inner run has replaced the tool’s result with the gate’s turn-ending message, so the outer gate reads a write-capable tool returning something that is not a proposal and seals a wireup-invalid refusal onto it — one filed card and a refusal that names the wrong problem. On the settled path — a standing-order auto-approval, a referral escalation, or an idempotent replay — the inner run deliberately leaves the tool’s own proposal as the result, so the outer gate does parse it, does reach a filing, derives the same entry id and gets a replay of that row back. Either way, never two rows.

Two guards exist, and both live inside Affiant.Extensions.AI and catch only its own wrapper: chatOptions.WithAffiant throws at wire-up when an Affiant wrapper is already on the tool list it is handed or among the catalog’s own functions, and AffiantDelegatingAIFunction’s invoke-time re-entrancy guard throws when a second wrapper of its own is entered under the same FunctionInvocationContext, whatever hides it.

Neither guard sees a second bridge, so nothing in the framework catches the cross-adapter mistake as such. The record the re-entrancy guard reads is a private AsyncLocal inside AffiantDelegatingAIFunction that nothing outside Affiant.Extensions.AI can set — and Affiant.AgentFramework does not reference that package at all. What stops the pairing in practice is unrelated to double-wrapping: both adapters’ AffiantToolCatalog.FromType<T>() defaults the plugin name to typeof(T).Name, so two catalogs over one tool type carry the same name and the second wire-up throws already registered from the singleton tool registry — a duplicate-descriptor error that says nothing about the real mistake. Give the second catalog a plugin name of its own and both wire up cleanly, and every call to that tool then fails before its body runs: both bridges look the descriptor up by function name alone, before the pipeline runs, and the registry refuses that lookup as ambiguous once descriptors for one function name exist under two plugin names. MAF over MAF is the same shape: agent.WithAffiant has no double-wrap check of its own, and a second call registers the same descriptors again, so it too throws already registered; a different plugin name on the second catalog only moves the failure to that ambiguous lookup. One bridge per catalog is a rule you hold, not one the framework holds for you.

Concern Semantic Kernel Microsoft Agent Framework Microsoft.Extensions.AI
Registration AddAffiantCore() + AddAffiantSemanticKernel() + AddAffiantInferenceOrchestration() AddAffiantCore() + AddAffiantAgentFramework() — one call covers the neutral filter positions 4–7 SK splits across its two; SK’s own extras (AffiantStartupValidator, CapabilityRegistry, IManualToolInvoker) have no counterpart here AddAffiantCore() + AddAffiantExtensionsAI() — same one-call shape as MAF, and the same absence of SK’s extras
Tool discovery [KernelFunction]-decorated methods, registered as SK plugins — every one needs a descriptor in the tool registry or AffiantStartupValidator refuses the host at startup: AddAffiantPluginsFromType<T>() / AddAffiantPluginsFromAssembly(...) walk the methods and register one each ([AffiantWriteTool] gives that write operation, everything else Operation.ReadQuery), or register each explicitly with AddAffiantTool<TStrategy> / AddAffiantReadTool AffiantToolCatalog.FromType<T>() reflects a tool type’s public instance methods — generic definitions, object members and property/event accessors are skipped, and an overloaded name throws AffiantToolCatalog.FromType<T>(), same shape as MAF
Attach point DI-registered filters on the Kernel agent.WithAffiant(services, catalog) — decorates, returns a new AIAgent chatOptions.WithAffiant(services, catalog) — decorates, returns a new ChatOptions
Provider abstraction IChatCompletionService + connector capabilities Microsoft.Extensions.AI.IChatClient Microsoft.Extensions.AI.IChatClient directly — no agent framework in between
Session/turn identity kernel.Data["ConversationId"] and kernel.Data["AffiantTurnNumber"], both host-populated (kernel.Data["ChatHistory"] is read separately, as history) ChatOptions.ConversationId on the run options, read from FunctionInvocationContext.Options.ConversationId ChatOptions.ConversationId — see below, this one is easy to skip by accident

WithAffiant(...) is the only supported way to attach Affiant on MAF or M.E.AI. Both wrapping calls produce a new instance rather than mutating the one you pass in — a pre-wrap AIAgent or ChatOptions local that anything in your codebase still calls bypasses Affiant entirely, with no error. Discard the unwrapped local, or shadow it, so only the wrapped instance is reachable after the wiring line.

Set ConversationId on Microsoft.Extensions.AI — or inference silently degrades

Section titled “Set ConversationId on Microsoft.Extensions.AI — or inference silently degrades”

The conversation id is host-supplied on all three backends, and the degradation when it is missing is backend-neutral too — the fallback lives in InferenceTriggerFilter, in Affiant.Core, not in any bridge. This section sits on M.E.AI because that is where the step is easiest to forget and the failure is silent. Affiant runs task inference once per (conversation, tool, turn). When ChatOptions.ConversationId is left null, there’s no conversation to key on, so the idempotency key falls back to the identity of the ambient IContextFabric — and wired the way the published M.E.AI quickstarts show it, that object is process-global: FunctionInvokingChatClient hands Affiant the provider the ChatClientBuilder was built from, and when that is the application root, one scoped fabric serves the whole process. Every conversation collapses onto the same key, and every conversation after the first skips write-tool inference for any (tool, turn number) pair an earlier one already ran — no exception, no warning, just Affidavits built from raw tool arguments with nothing inferred.

The wiring below has two halves on two different clocks. The catalog and the wrapping happen once, while the host is startingWithAffiant registers each descriptor with the singleton tool registry, whose Register is a TryAdd keyed on (function name, plugin name) that throws on the first duplicate. So WithAffiant called once per turn over the same tool type succeeds on turn 1 and throws on turn 2: already registered when the turn builds a fresh catalog, and the double-wrap refusal above when it re-wires the options turn 1 returned. Keep what it returns as the template every turn clones. The client is built per turn, from that turn’s own scope, because the provider it is built from is the provider every tool call resolves the scoped ContextFabric against. And UseFunctionInvocation() is as load-bearing as the conversation id, and fails just as quietly: Affiant intercepts by wrapping each AIFunction, and it is FunctionInvokingChatClient that invokes those functions; build the client without it and the model’s tool calls come back to your host unexecuted, so no wrapper runs, nothing is tagged, gated, or filed, and nothing reports it.

using Affiant.Extensions.AI;
using Affiant.Extensions.AI.Extensions;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
// Once, while the host is starting. WithAffiant registers this catalog's descriptors with the
// singleton tool registry, which refuses a duplicate — call it again for the same tool type and
// it throws "already registered".
var catalog = AffiantToolCatalog.FromType<MyTools>();
ChatOptions template = new ChatOptions { Tools = [.. catalog.Functions] }
.WithAffiant(serviceProvider, catalog);
// Per turn: open a scope and build the function-invoking client FROM THAT SCOPE, so the fabric
// every tool call resolves belongs to this turn. UseFunctionInvocation() is what runs the tool
// loop and publishes the per-call context Affiant's wrapper reads; without it, Affiant never
// sees a tool call.
using var turn = serviceProvider.CreateScope();
IChatClient client = new ChatClientBuilder(innerClient)
.UseFunctionInvocation()
.Build(turn.ServiceProvider);
// Required, not optional — see above.
ChatOptions chatOptions = template.Clone();
chatOptions.ConversationId = conversationId;
var response = await client.GetResponseAsync(messages, chatOptions);

Both halves earn their line, and each fixes a different thing. Setting ConversationId per conversation fixes the inference-idempotency half — and only that half. Everything else a shared fabric holds still bleeds across conversations: ToolArgumentCaptureFilter upserts a tool’s arguments onto one entity keyed by the tool’s declared entity type, with the bare argument names as that entity’s fields, and TaskInferenceStep records each field’s provenance chain under the field name alone — so one process-global fabric lets each conversation read and overwrite the last one’s. Building the client from a per-turn scope is what fixes that half, and it is the same wiring Using Affiant with Microsoft.Extensions.AI shows. Setting the id also covers only the first round of a turn: FunctionInvokingChatClient re-derives the in-flight options’ ConversationId from each response, so against a provider whose own responses carry no conversation id, every tool call from the second round on reads null and takes the fabric fallback again — the behaviour of Microsoft.Extensions.AI 10.9.0, the version the M.E.AI bridge pins. The host’s own ChatOptions object is left untouched; it is the copy in flight that changes, and nothing in the framework restores the id, so closing that gap is the host’s job at 1.0.0-beta.3.

Each bridge hands the pipeline the provider it has at hand — SK the Kernel’s own Services, MAF and M.E.AI the AIFunctionArguments.Services their invoking client supplies. MAF and M.E.AI are pinned to the application root in their documented wirings, because the root is what the invoking client was built from. Semantic Kernel’s documented wiring is not: the framework’s own tool-authoring guide tells hosts to resolve the Kernel from the per-request scope, and the shipped sample injects it into a SignalR hub, which gets one DI scope per invocation — so an SK host wired the documented way already has a per-turn fabric, and the SK host that shares the defect is the one that resolves a root Kernel instead. A framework-owned per-turn scope is not part of 1.0.0-beta.3. Each backend has its own place to put the conversation id: kernel.Data["ConversationId"] on SK, the run’s ChatOptions.ConversationId on MAF — see Using Affiant with Microsoft Agent Framework — and the wired ChatOptions on M.E.AI. It’s the M.E.AI wiring path where it’s easiest to forget, since nothing else there requires you to touch ConversationId at all.

Every backend draws the identical line: Affiant sees only locally-invoked tool calls. A tool executed by the model provider’s own runtime — hosted MCP, code interpreter, web search, and similar provider-executed tools — never enters any backend’s client-side invocation pipeline, so no backend’s bridge can tag, gate, or swear to a write that happens inside one. This is architecturally true, not a missing feature — see The Honest Boundary for the full shape of it and what to do about the gap.

Affiant.AgentFramework and Affiant.Extensions.AI make the boundary structural rather than a silent gap: each audits the tool set at wire-up time and refuses by default if it finds an uncovered hosted tool, naming every one. MAF has a second refusal path, because it has to find the tool list first: it probes agent.GetService(typeof(ChatOptions)). ChatClientAgent is the only concrete AIAgent in Microsoft.Agents.AI 1.13.0 that answers that itself; the four other public ones it ships (LoggingAgent, OpenTelemetryAgent, and the experimental LoopAgent and ToolApprovalAgent) are DelegatingAIAgents, and DelegatingAIAgent forwards GetService to the agent it wraps — as do the delegating agents the package keeps internal — so a wrapper over a ChatClientAgent is auditable too. An agent that answers null cannot be audited at all, so agent.WithAffiant refuses it outright unless AgentFrameworkOptions.AllowUnauditableAgent is set, which downgrades that refusal to a logged warning naming the agent type. M.E.AI needs no such switch: the host builds the ChatOptions and hands them in, so the tool list is always enumerable. Semantic Kernel has no audit at all — the boundary holds there too, but nothing checks it:

using Affiant.AgentFramework.Extensions;
using Affiant.Extensions.AI.Extensions;
using Microsoft.Extensions.DependencyInjection;
// MAF
builder.Services.AddAffiantAgentFramework(options =>
{
options.AcknowledgeUncoveredTools = ["code_interpreter"];
});
// M.E.AI
builder.Services.AddAffiantExtensionsAI(options =>
{
options.AcknowledgeUncoveredTools = ["code_interpreter"];
});

Each acknowledgment emits a telemetry span and a logged warning at wrap time, so an accepted gap is auditable, never silent.

Using Affiant with Microsoft Agent Framework covers the MAF bridge in full, including migrating an existing SK host, and Using Affiant with Microsoft.Extensions.AI does the same for the M.E.AI bridge and its ConversationId requirement. Packages has the package-level dependency picture for all three bridges. The Honest Boundary covers the hosted-tool limit these three backends share. Quickstart is the end-to-end worked example, built against Semantic Kernel; the same Affidavit → Docket → Evidence Card → IWriteExecutor flow applies unchanged on MAF and M.E.AI, because all three terminate in the same backend-neutral ReviewGate.