Skip to content

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

  1. If MinimumConfidenceThreshold is set and the candidate’s confidence falls below it, the field is skipped — recorded as not merged, with a reason, but otherwise ignored.
  2. A ProvenanceTag is built via ProvenanceTag.FromInference(fieldName, confidence) — source Inferred.
  3. The candidate is compared against whatever ProvenanceChain ContextFabric.GetFieldChain already holds for that field name. If nothing exists yet, the candidate wins outright. If a chain exists, ProvenanceChain.Merge decides: higher confidence wins; on a tie, the source with the lower ProvenanceSource enum ordinal wins (more deterministic beats less deterministic). The losing tag is never discarded — it’s preserved in Prior.
  4. ContextFabric.SetFieldChain records 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.
  5. Only if the candidate won does its value get upserted into the fabric’s EntityRef for strategy.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.

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.