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.
Picking one
Section titled “Picking one”| 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.
How each backend attaches
Section titled “How each backend attaches”| 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 starting — WithAffiant 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.
The hosted-tool boundary, on all three
Section titled “The hosted-tool boundary, on all three”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;
// MAFbuilder.Services.AddAffiantAgentFramework(options =>{ options.AcknowledgeUncoveredTools = ["code_interpreter"];});
// M.E.AIbuilder.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.
Where this fits
Section titled “Where this fits”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.