Context Fabric
Semantic Kernel’s filter pipeline gives Affiant a place to sit between the LLM and a tool call
— before it runs, after it returns, or both. ContextFabric is what those filters read from
and write to instead of the conversation transcript. It is the framework’s single place to ask
“what does the agent currently know, and how sure is it?” — a question that neither a raw chat
history nor a prompt string can answer deterministically.
ContextFabric: what it holds
Section titled “ContextFabric: what it holds”ContextFabric is a sealed class in Affiant.Core.Services that tracks two things: entities
(as EntityRef, covered in Tool Envelopes) and, separately, a
ProvenanceChain per field key — the audit trail behind a single field’s current value, from
Affidavits & Provenance.
public sealed class ContextFabric : IContextFabric{ public void Upsert(EntityRef entityRef); public EntityRef? GetByKey(string entityKey); public Dictionary<string, EntityRef> Snapshot(); public void MergeFrom(IEnumerable<EntityRef> entityRefs); public void Clear();
public ProvenanceChain? GetFieldChain(string fieldKey); public void SetFieldChain(string fieldKey, ProvenanceChain chain);}Upsert is a merge, not a replace: if an EntityRef with the same EntityId already exists,
the incoming call’s Fields overlay the existing ones key-by-key — fields not present in the
new call are preserved — and DisplayName takes the incoming value. This is what lets a
customer fetched by one read tool accumulate additional fields from a second, unrelated read
tool later in the same conversation, without either tool needing to know about the other.
GetFieldChain / SetFieldChain are the field-level counterpart, keyed by field name rather
than entity ID. They exist specifically to support the confidence-based merge rule that
TaskInferenceStep runs — covered below — by giving it somewhere durable to read the current
ProvenanceChain for a field before deciding whether a new candidate value should win.
IContextFabric — the interface ContextFabric implements — lives in
Affiant.Abstractions.Interfaces, separate from the concrete class in Affiant.Core:
public interface IContextFabric{ ProvenanceChain? GetFieldChain(string fieldName); void SetFieldChain(string fieldName, ProvenanceChain chain); EntityRef? GetByKey(string key); void Upsert(EntityRef entity);}The split matters for the package DAG: Affiant.Abstractions has zero dependencies on other
Affiant packages, so an Abstractions-level interface like IDeterministicFieldSource (see
Affidavits & Provenance) can take an IContextFabric
parameter without Abstractions needing to depend on Core, where the concrete ContextFabric
class lives. See Packages for the full dependency graph.
A lifetime note worth knowing before you deploy. The framework’s own AddAffiantCore() DI
registration extension registers ContextFabric with TryAddSingleton — meaning it only wins
if nothing has already registered ContextFabric by the time AddAffiantCore() runs. A
process-wide singleton is fine for a single-conversation scenario, but a real multi-user host
almost certainly wants one ContextFabric instance per conversation, not one shared across
every user connected to the process. Register ContextFabric yourself, scoped to whatever
per-conversation lifetime your host uses (a DI scope created per chat session is the common
shape), before calling AddAffiantCore() — TryAddSingleton steps aside for a registration
that’s already there.
ContextExtractor: filling the fabric from read tools
Section titled “ContextExtractor: filling the fabric from read tools”ContextExtractor is an abstract base class in Affiant.Core.Filters that hosts subclass once
per read tool (or per closely related group of read tools) whose results are worth remembering
across turns:
public abstract class ContextExtractor : IFunctionInvocationFilter{ protected ContextExtractor(ContextFabric contextFabric, ILogger logger);
protected abstract bool MatchesTool(string toolName); protected abstract Task ExtractAsync(ReadResult result, FunctionInvocationContext context); protected void EmitEntity(EntityRef entityRef);}The base class implements Semantic Kernel’s IFunctionInvocationFilter and does the
undifferentiated work: it calls next(context) to let the wrapped function run first, then
checks MatchesTool(context.Function.Name), deserializes the function’s JSON result as a
ToolEnvelope, and — only if it comes back as a ReadResult with a non-empty Entities array
— calls the subclass’s ExtractAsync. A subclass’s job is just the domain-specific two lines:
public class CustomerSearchExtractor(ContextFabric contextFabric, ILogger<CustomerSearchExtractor> logger) : ContextExtractor(contextFabric, logger){ protected override bool MatchesTool(string toolName) => toolName.Equals("SearchCustomers", StringComparison.OrdinalIgnoreCase);
protected override Task ExtractAsync(ReadResult result, FunctionInvocationContext context) { foreach (var entity in result.Entities) EmitEntity(entity); return Task.CompletedTask; }}EmitEntity calls ContextFabric.Upsert and logs at debug level; a subclass never touches
ContextFabric.Upsert directly or parses JSON itself. Register the subclass the same way you’d
register any Semantic Kernel filter: services.AddScoped<IFunctionInvocationFilter, CustomerSearchExtractor>().
Not every read tool needs one. A tool with no meaningful entities to remember — “what’s today’s
date?” — returns an empty Entities array and there is nothing for an extractor to do.
TaskInferenceStep: merging inferred values deterministically
Section titled “TaskInferenceStep: merging inferred values deterministically”Where ContextExtractor fills the fabric from a read tool’s structured results,
TaskInferenceStep merges inferred field values — produced by an LLM given a schema — into
the same fabric, using the confidence hierarchy from
Affidavits & Provenance to decide whether a candidate
value should overwrite what’s already there:
public sealed class TaskInferenceStep{ public TaskInferenceStep(ContextFabric contextFabric, ILogger<TaskInferenceStep> logger);
public Task<TaskInferenceResult> ExecuteAsync( ITaskInferenceStrategy strategy, JsonElement llmStructuredOutput, CancellationToken cancellationToken = default);}The strategy parameter — an ITaskInferenceStrategy — is a host-authored schema for one write
tool, not a DI-registered singleton the step owns itself:
public interface ITaskInferenceStrategy{ string EntityName { get; } IReadOnlyList<TaskInferenceField> Fields { get; } double? MinimumConfidenceThreshold { get; }}
public record TaskInferenceField( string Name, string JsonType, string Description, int? MaxLength = null, string? Pattern = null, IReadOnlyList<string>? Enum = null, bool Required = false);Fields declares the shape the framework asks the LLM to produce as structured output — each
field’s JSON type, an optional max length or regex pattern, an optional closed value set, and
whether it’s required. EntityName is the key TaskInferenceStep upserts merged values under
in the fabric.
ExecuteAsync expects llmStructuredOutput to be a JSON object where each property matches a
declared field name and carries a "value" and a "confidence" (a float, or a string parsed
as one). For each field present in both the schema and the LLM’s response:
- If
MinimumConfidenceThresholdis set and the candidate’s confidence falls below it, the field is skipped — recorded as not merged, with a reason, but otherwise ignored. - A
ProvenanceTagis built viaProvenanceTag.FromInference(fieldName, confidence)— sourceInferred. - The candidate is compared against whatever
ProvenanceChainContextFabric.GetFieldChainalready holds for that field name. If nothing exists yet, the candidate wins outright. If a chain exists,ProvenanceChain.Mergedecides: higher confidence wins; on a tie, the source with the lowerProvenanceSourceenum ordinal wins (more deterministic beats less deterministic). The losing tag is never discarded — it’s preserved inPrior. ContextFabric.SetFieldChainrecords the (possibly updated) chain regardless of which side won, so the fabric always reflects the fullest picture of what’s been proposed for that field, not just what’s currently winning.- Only if the candidate won does its value get upserted into the fabric’s
EntityRefforstrategy.EntityName.
This is the concrete mechanism behind the merge rule described in
Affidavits & Provenance: “when a task-inference step
produces an LLM-inferred value for a field the fabric already holds from a higher-confidence
source, the higher-confidence value wins.” A Conversation-sourced value extracted by a
ContextExtractor earlier in the conversation — confidence 0.9 via ProvenanceTag.FromTool —
beats a same-turn Inferred guess at confidence 0.6 without either value being thrown away or
requiring a prompt instruction like “don’t overwrite a value you already know.” The comparison
is arithmetic on two records; there is nothing for the LLM to get right or wrong about it.
Affiant.Core.Filters.TaskInferenceMergeFilter is the IAutoFunctionInvocationFilter that
wires TaskInferenceStep into the Semantic Kernel pipeline automatically: it fires after each
auto-invoked function, checks whether the tool has a registered InferenceStrategy (via its
AffiantToolDescriptor in IAffiantToolRegistry), and — only for tools that do — forwards the
JSON result to TaskInferenceStep.ExecuteAsync. Read tools and any function without a
registered write descriptor are skipped without error. A host’s Affiant.SemanticKernel
registration can additionally run inference before a write tool executes rather than after;
that pre-tool variant lives in the Affiant.SemanticKernel package and uses the same
TaskInferenceStep merge logic underneath.
DeterministicShortCircuit: bypassing the tool body entirely
Section titled “DeterministicShortCircuit: bypassing the tool body entirely”ContextExtractor and TaskInferenceMergeFilter both let the wrapped function run and act on
its result afterward. DeterministicShortCircuit is the one piece of the pipeline positioned to
prevent the wrapped function from running at all:
public sealed class DeterministicShortCircuit(IEnumerable<IIntentInterceptor> interceptors) : IFunctionInvocationFilter{ public async Task OnFunctionInvocationAsync( FunctionInvocationContext context, Func<FunctionInvocationContext, Task> next) { IReadOnlyDictionary<string, object?> args = context.Arguments .ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
foreach (var interceptor in interceptors) { if (await interceptor.MatchesAsync(args, context.CancellationToken)) { var result = await interceptor.HandleAsync(args, context.CancellationToken); context.Result = new FunctionResult(context.Function, result); return; // next(context) is never called — the wrapped function body never runs } }
await next(context); }}IIntentInterceptor is the extension point a host implements:
public interface IIntentInterceptor{ Task<bool> MatchesAsync(IReadOnlyDictionary<string, object?> arguments, CancellationToken cancellationToken = default); Task<object?> HandleAsync(IReadOnlyDictionary<string, object?> arguments, CancellationToken cancellationToken = default);}DeterministicShortCircuit iterates every registered IIntentInterceptor, in registration
order, and asks each one whether the current tool call’s arguments match a condition it owns.
The first interceptor to answer true gets to produce the result directly via HandleAsync —
and the loop stops there; no other interceptor is consulted, and the function the LLM actually
asked to invoke never executes. This is for high-failure-cost intents a host can resolve from
arguments alone, deterministically, without needing a live tool call — the interceptor, not the
tool body or the model, is the source of truth for that specific case.
Rule 4: filters over prompts for determinism
Section titled “Rule 4: filters over prompts for determinism”All three pieces on this page exist because of the fourth of the framework’s Seven Normative Rules: context extraction, task inference, and review gating happen in Semantic Kernel filters, never in prompt engineering. Prompts request tool calls; filters process the results deterministically. The anti-pattern the rule rules out is an instruction appended to a system prompt like “after calling the tool, extract the customer’s email from the result” — an instruction whose success depends on the model faithfully following it, varies across model providers and even across calls to the same model, and leaves no code path to audit when it silently doesn’t happen.
ContextExtractor, TaskInferenceStep, and DeterministicShortCircuit are the filter-side
alternative to each of the three things a prompt-based approach would otherwise ask a model to
self-report: which entities came out of a read result, which field values should win when two
sources disagree, and which tool calls are deterministic enough to skip the model’s involvement
entirely. None of the three depends on the model volunteering the right behavior — they run as
ordinary C# in the invocation pipeline whether the underlying provider is faithful about
instructions or not.
Where this fits
Section titled “Where this fits”Once fields have been extracted and merged into ContextFabric, something still has to turn
fabric state into the Affidavit a WriteProposal carries — that’s IAffidavitProjection
(default implementation SchemaDrivenAffidavitProjection in Affiant.Core.Services), which
reads each field the active ITaskInferenceStrategy declares, checks any registered
IDeterministicFieldSource overrides first, falls back to the fabric’s ProvenanceChain, and —
per Rule 7 — tags anything neither source can resolve as ProvenanceTag.Empty rather than
omitting it. See Affidavits & Provenance for that rule
and Tool Envelopes for what the resulting Affidavit is wrapped in
on its way out of a write tool.