Authoring Read Tools
A read tool fetches and presents existing state. It has no side effects, proposes no mutation,
and never touches a write path. The framework’s own
tool-authoring guide
gives Task<string> as the signature of a Semantic Kernel [KernelFunction] read tool; the Agent
Framework and Microsoft.Extensions.AI adapters run one and the same reflection pass over a tool
type’s public instance methods, and neither of them tests the return type, so a synchronous
string-returning method is a tool on both. What is common to all three is the envelope: whatever
the method returns, the string it produces carries a ToolEnvelope serialised with
.ToJsonString() — and for a read tool that envelope is ReadResult, one of the three
ToolEnvelope variants:
public sealed record ReadResult( string ToolName, DateTimeOffset Timestamp, string Summary, string Markdown, EntityRef[] Entities) : ToolEnvelope(ToolName, Timestamp);Summary is a short sentence for the LLM’s own reasoning. Markdown is the fuller result, and it
is dual-audience by contract — the framework’s own words for it are “markdown for dual-audience
consumption (LLM + UI)”: the model reads and quotes it, a chat UI renders it. Entities is the
structured half of the pair — a domain-agnostic EntityRef[] that a later filter reads instead of
re-parsing the markdown. This page covers the pattern for building all three, plus the
ContextExtractor that carries Entities into the Context Fabric, where the turn’s later filters
and the affidavit projection can reach them. See
Tool Envelopes for the full type reference and
The Seven Normative Rules for Rule 2, the dual-audience
requirement this shape exists to satisfy.
The code on this page is the Semantic Kernel shape — [KernelFunction],
Plugins.AddFromType<T>(), AddAffiantReadTool and the SK adapter’s boot-time registry check.
What is backend-neutral is the envelope contract and ContextExtractor; the registration is not.
A read descriptor can also be generated for you, on all three backends. On Semantic Kernel the
walker is Affiant’s own kernelBuilder.AddAffiantPluginsFromType<T>(), which registers an
Operation.ReadQuery descriptor for every [KernelFunction] carrying no [AffiantWriteTool],
with the plugin name defaulting to typeof(T).Name. Its sibling
AddAffiantPluginsFromAssembly(assembly, pluginName) walks every type in an assembly and stamps
the one pluginName you pass onto every descriptor it makes; that parameter defaults to null,
and a descriptor with a null plugin name does not satisfy the boot check described below, which
looks each [KernelFunction] up by the exact (function name, plugin name) pair — so pass a plugin
name explicitly where the assembly holds a single plugin class registered under that same name, and
call the per-type overload once per plugin class otherwise. On the Agent Framework and
Microsoft.Extensions.AI bridges AffiantToolCatalog.FromType<T>() does the same for every public
instance method without that attribute — skipping generic method definitions, members declared on
object, and special-name members such as property accessors — and WithAffiant(...) registers
what it produced. AddAffiantReadTool is the explicit registration for a plugin no walker has
covered, and the only one of these three routes that can put an entity type on a read descriptor —
every generated read descriptor carries EntityType: null, which costs the argument capture
described under Registering the tool. Declaring the same tool both ways
registers the same (function name, plugin name) twice, and whichever registration runs second throws
an InvalidOperationException opening Tool descriptor for (SearchBooks, SearchBooksPlugin) is already registered. and going on to print both descriptors. For how the same tool method is
registered and named on the other two bridges, see
Microsoft Agent Framework and
Microsoft.Extensions.AI.
Worked example: searching a catalog
Section titled “Worked example: searching a catalog”The examples on this page use a small library-lending domain: a Book entity
(BookId, Title, Author, Isbn, IsAvailable) queried through a LibraryDbContext exposing
DbSet<Book> Books. Nothing here is framework-specific — swap in your own entity and DbContext.
using System.ComponentModel;using System.Text;using Affiant.Abstractions.Models;using Microsoft.EntityFrameworkCore;using Microsoft.Extensions.DependencyInjection;using Microsoft.SemanticKernel;
public class SearchBooksPlugin(IServiceScopeFactory scopeFactory){ [KernelFunction, Description("Search the library catalog by title (partial match, " + "case-insensitive), author (exact match, case-insensitive), or availability. " + "Returns a markdown table and entity references extracted into conversation context. " + "Omit all parameters to list all books (max 100).")] public async Task<string> SearchBooks( [Description("Partial title to search for. Omit if not filtering by title.")] string? titleQuery = null, [Description("Exact author name to match. Omit if not filtering by author.")] string? author = null, [Description("If true, only return books currently available for loan. Omit to include all.")] bool? availableOnly = null, CancellationToken cancellationToken = default) { const string toolName = "SearchBooks";
try { // LibraryDbContext is Scoped and a plugin registered with Plugins.AddFromType<T>() is // a process-wide singleton, so the context is resolved per invocation from a fresh // scope — never injected into this plugin's constructor. The anti-pattern section // below has the mechanism. using var scope = scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService<LibraryDbContext>();
// Composable query — only add a filter for parameters the caller actually supplied. var query = dbContext.Books.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(titleQuery)) query = query.Where(b => b.Title.ToLower().Contains(titleQuery.Trim().ToLower()));
if (!string.IsNullOrWhiteSpace(author)) query = query.Where(b => b.Author.ToLower() == author.Trim().ToLower());
if (availableOnly == true) query = query.Where(b => b.IsAvailable);
var books = await query.OrderBy(b => b.Title).Take(100).ToListAsync(cancellationToken);
// Markdown for the LLM — a table it can read and quote from. var sb = new StringBuilder(); sb.AppendLine($"## Search Results: {books.Count} book(s) found"); sb.AppendLine();
if (books.Count == 0) { sb.AppendLine("*No books matched the search criteria.*"); } else { sb.AppendLine("| Book | Author | ISBN | Available |"); sb.AppendLine("|------|--------|------|-----------|"); foreach (var book in books) { var availability = book.IsAvailable ? "Yes" : "No"; sb.AppendLine($"| [Book:{book.BookId}] {book.Title} | {book.Author} | {book.Isbn} | {availability} |"); } }
// EntityRef[] for the context fabric, which keys entities by EntityId. The built-in // affidavit projection reads one fabric entry — GetByKey(strategy.EntityName) — and // matches its field keys by name against the fields the write's inference strategy // declares. A catalog row keyed by its own id, as here, is not that entry: to be the // subject of a write an entity has to be keyed by that strategy's EntityName and name // its fields the way the strategy names them. var entities = books.Select(book => new EntityRef( EntityType: "Book", EntityId: book.BookId.ToString(), DisplayName: book.Title, Fields: new Dictionary<string, object> { ["Title"] = book.Title, ["Author"] = book.Author, ["Isbn"] = book.Isbn, ["IsAvailable"] = book.IsAvailable, })).ToArray();
return new ReadResult(toolName, DateTimeOffset.UtcNow, $"Found {books.Count} book(s)", sb.ToString(), entities).ToJsonString(); } catch (Exception ex) when (ex is TimeoutException or DbUpdateException) { return new ToolError(toolName, DateTimeOffset.UtcNow, "DB_TIMEOUT", "Database is temporarily unavailable. Please try again.", Retryable: true).ToJsonString(); } }}A few things worth calling out:
AsNoTracking()is essential on a read path — it skips EF Core’s change-tracking overhead for data you’re never going to save.- An empty
EntityRef[]is a normal, successful result, not an error. A query that legitimately finds nothing returns zero entities; reach forToolErroronly when the query itself failed. - A fresh scope per invocation is how a plugin reaches a
Scopeddependency at all. A plugin registered withPlugins.AddFromType<T>()— the path this page uses and the one the framework’s own guide teaches — is a process-wide singleton, soIServiceScopeFactoryis the rule for any dependency of such a plugin that the host registersScoped, not a variation on a theme. Semantic Kernel itself is not the constraint: a host that registers aKernelPluginof its own per scope (KernelPluginFactory.CreateFromType<T>(null, sp)) on aKernelresolved from that same scope does get a freshScopeddependency per turn. The anti-pattern below is about theAddFromTypepath. Retryableon aToolErrora tool returns is advisory. The framework’s one automatic retry isToolErrorFilter’s, and it fires only for an error that filter mapped from an exception it caught at a seam whosenext()is the tool body — never for an error a tool hands back as its own result. That one becomes the tool’s visible result; itsRetryablevalue is recorded as a tag on theaffiant.tool_errortelemetry event and nothing re-invokes anything..ToJsonString(), fromAffiant.Abstractions.Models.ToolEnvelopeExtensions, is how every tool return gets to the wire. At1.0.0-beta.3it serializes throughAffiantJson.SerializerOptions, the one set of conventions every Affiant envelope is written under: camelCase property names, enums as strings in the casing each schema freezes (ProvenanceSourcePascalCase,ReviewStatuscamelCase), one ISO spelling for every instant, and a null written asnullrather than omitted — save on the few schema-optional properties that carry their own[JsonIgnore(WhenWritingNull)]— plus thekinddiscriminator ($typebefore1.0.0-beta.3) that makes polymorphic deserialization possible downstream. Before this release the method configured camelCase and nothing else, so an enum inside a tool result crossed as an integer while the same enum inside an Evidence Card crossed as a string.
The [Book:id] reference
Section titled “The [Book:id] reference”Rule 2 in the
framework specification
asks for a stable, quotable identifier for anything a read tool’s markdown names: “Read tools
return markdown tables with embedded [entity:id](link) references.” That identifier form is the
specification’s. The site’s Rule 2 states the dual-audience
requirement it serves — markdown plus structured entity references — without prescribing the
bracket shape, so read the two together. The worked example above carries the identifier half of
it: each row’s Book column emits [Book:{book.BookId}] ahead of the title — a bare bracket with
no link target, which is all a model needs to quote the id back. The exact bracket text isn’t
mechanically enforced by any type — ReadResult.Markdown is a plain string — so it’s a
formatting discipline for the plugin author, not something the compiler checks. What it buys you is
real: a model that reads “[Book:42] The Hobbit is available” can quote 42 back verbatim in a
later tool call — a loan request, an update — without you having to re-parse prose to recover which
row it meant.
Registering the tool
Section titled “Registering the tool”Once SearchBooksPlugin is registered with the kernel — ordinary Semantic Kernel, not
Affiant-specific: builder.Services.AddKernel().Plugins.AddFromType<SearchBooksPlugin>() — it
also needs an entry in the framework’s own tool registry. AddAffiantReadTool is an extension
method on IServiceCollection in Affiant.Core.Extensions:
builder.Services.AddAffiantReadTool( functionName: "SearchBooks", entityType: "Book", pluginName: nameof(SearchBooksPlugin));Call this after AddAffiantCore(). If your host also calls AddAffiantSemanticKernel(), its
AffiantStartupValidator checks every [KernelFunction] against the registry at boot and throws
AffiantStartupException, naming the exact method, if it can’t find a matching descriptor — this
is what confirms a tool is really registered as read-only rather than accidentally left
unclassified.
entityType on a read descriptor is not decoration. ToolArgumentCaptureFilter — a pre-tool filter
that AddAffiantAgentFramework and AddAffiantExtensionsAI each register in their one call, and
that on Semantic Kernel comes from the separate AddAffiantInferenceOrchestration() call — looks
the called tool up in the registry, and for any descriptor whose EntityType is non-empty it
upserts the arguments that carried a value into the Context Fabric as an EntityRef whose type, id
and display name are all that entity type, with the argument names as its field keys. So where that
filter is registered, entityType: "Book" here also puts each SearchBooks call’s arguments into
the fabric under the key Book — the key the built-in projection reads for a write strategy naming
Book as its EntityName, though the field keys there are the plugin’s parameter names, not the
strategy’s field names. The filter mints no provenance for them: an argument is a value, not
evidence of where the value came from. Leave entityType null and a read tool’s arguments are never
captured; a Semantic Kernel host that calls AddAffiantSemanticKernel() and not
AddAffiantInferenceOrchestration() has no ToolArgumentCaptureFilter to capture them either way.
This explicit call is one of two Semantic Kernel paths — the other is the walker,
kernelBuilder.AddAffiantPluginsFromType<SearchBooksPlugin>(). The two do not produce the same
descriptor: the walker emits EntityType: null for every read it finds, so a tool registered that
way carries the (function name, plugin name, Operation.ReadQuery) triple and none of the argument
capture above. AffiantToolCatalog.FromType<T>(), which produces the declaration on the Agent
Framework and Microsoft.Extensions.AI bridges for WithAffiant(...) to register, does the same —
an automatically generated read descriptor never names an entity type on any of the three backends.
Take one route per tool, not two. See Quickstart for the equivalent
registration step on the write side, AddAffiantTool<TStrategy>.
The anti-pattern: a scoped DbContext in a plugin constructor
Section titled “The anti-pattern: a scoped DbContext in a plugin constructor”Plugins.AddFromType<T>() — the registration above — registers the KernelPlugin as a DI
singleton and builds the plugin instance once, from the root provider; that registration path
has no per-invocation plugin lifetime, and it never registers T itself, so the container’s
ValidateOnBuild pass cannot see inside it and Build() succeeds. Constructor-injecting a
Scoped service such as a DbContext into a plugin registered that way is therefore always a
captive dependency rather than a conditional risk:
Do not do this: public class SearchBooksPlugin(LibraryDbContext dbContext)
With ServiceProviderOptions.ValidateScopes on — the default in ASP.NET Core’s Development
environment — the first Kernel resolution throws InvalidOperationException: Cannot resolve scoped service 'LibraryDbContext' from root provider. In a host that calls
AddAffiantSemanticKernel() that resolution happens at boot: the adapter’s
AffiantStartupValidator runs as a hosted service and resolves the kernel in StartAsync from a
scope it creates — which does not save it, because the plugin is a singleton the root provider
builds whichever scope asks — so the throw arrives before any turn does. With scope validation off
nothing throws at all: the plugin is constructed once against the root provider, and that root
DbContext is then shared, unsynchronized, by every concurrent conversation for the lifetime of
the process. That silent case is the dangerous one.
Resolving per invocation through IServiceScopeFactory, as the worked example does, is the rule for
a plugin registered this way rather than an alternative. IServiceScopeFactory is supplied by the
container itself — you don’t need to register it.
Context extraction: carrying entities across turns
Section titled “Context extraction: carrying entities across turns”A read tool’s Entities are inert until something stores them. That something is a
ContextExtractor — an abstract base class in Affiant.Core.Filters that hosts subclass once per
read tool (or per closely related group) whose results are worth keeping. The listing below is the
type’s shape, not code to paste — every public and protected member is named, only the bodies are
omitted:
public abstract class ContextExtractor : IToolInvocationFilter{ protected readonly ContextFabric ContextFabric; protected readonly ILogger Logger;
protected ContextExtractor(ContextFabric contextFabric, ILogger logger);
public Task OnToolInvocationAsync( ToolInvocationContext context, Func<ToolInvocationContext, Task> next, CancellationToken cancellationToken = default);
protected abstract bool MatchesTool(string toolName); protected abstract Task ExtractAsync(ReadResult result, ToolInvocationContext context); protected void EmitEntity(EntityRef entityRef);}IToolInvocationFilter is the framework’s own backend-neutral filter interface, in
Affiant.Abstractions.Interfaces — not a Semantic Kernel type — so one extractor runs unchanged on
all three backends. The base class does the undifferentiated work: it awaits next(context) to let
the wrapped tool run first, checks MatchesTool against context.FunctionName, tests the result
text for the kind discriminator and deserializes it as a ToolEnvelope only if that is there
(the test is a plain substring search for "kind", so a result that does not carry the
discriminator is skipped without a parse attempt, while any JSON that does carry it — including an
envelope a host serialised itself through AffiantJson.SerializerOptions — is parsed), and — only
if it comes back as a ReadResult with a non-empty Entities array — calls your ExtractAsync.
A bug in your override never costs the tool its result: the base catches every non-cancellation
exception, logs it, and emits an affiant.extractor.failed event, leaving context.Result
untouched. A subclass’s job is the domain-specific two lines:
using Affiant.Abstractions.Interfaces;using Affiant.Abstractions.Models;using Affiant.Core.Filters;using Affiant.Core.Services;using Microsoft.Extensions.Logging;
public class BookSearchExtractor( ContextFabric contextFabric, ILogger<BookSearchExtractor> logger) : ContextExtractor(contextFabric, logger){ // OrdinalIgnoreCase matches the backends' own tool-name comparison. protected override bool MatchesTool(string toolName) => toolName.Equals("SearchBooks", StringComparison.OrdinalIgnoreCase);
protected override Task ExtractAsync(ReadResult result, ToolInvocationContext context) { foreach (var entity in result.Entities) EmitEntity(entity); return Task.CompletedTask; }}Register it against the neutral filter interface — the pipeline resolves every registered
IToolInvocationFilter and the backend bridge selects which of them run at the seam it is on; an
extractor runs at the invocation seam on all three:
builder.Services.AddScoped<Affiant.Abstractions.Interfaces.IToolInvocationFilter, BookSearchExtractor>();EmitEntity calls ContextFabric.Upsert and logs at debug level, so a subclass needs neither the
protected ContextFabric field nor any JSON parsing of its own. Not every read tool needs one: a
tool with nothing worth keeping (a “what’s today’s date?” query) legitimately returns an empty
Entities array, and there’s nothing for an extractor to do with it.
How long what it stores lives is the fabric’s business, not the extractor’s. ContextFabric is
registered Scoped, and ToolInvocationPipeline resolves it — and every filter — from the scope
the bridge hands it: the kernel’s own scope on Semantic Kernel, AIFunctionArguments.Services on
the Agent Framework and Microsoft.Extensions.AI. Build that scope per turn and one turn’s tool
calls share one fabric while concurrent turns stay isolated; build the chat client once from the
root provider and every conversation shares a single fabric instead (and an Agent Framework run
that wires no provider at all leaves the pipeline to own a fresh scope, and a fresh fabric, per
tool call). Either way, nothing in the framework carries a fabric into the next turn: a host that
wants a read tool’s entities available in a later turn persists them itself —
ContextFabric.Snapshot() into IDocketStore.SaveContextAsync, which stores a
ConversationContext, the same EntityRefs keyed by entity id, and LoadContextAsync back
through ContextFabric.MergeFrom when the next turn begins. Nothing in the framework performs the
save; the load half has one framework caller, the Semantic Kernel adapter’s
SessionRehydrator.RehydrateAsync, which reads the stored ConversationContext for a reconnecting
session and hands it to the host to merge back. See Context Fabric for
that lifetime in full.
Testing an extractor that only calls EmitEntity is usually unnecessary on its own — the
read plugin’s own integration test, asserting ReadResult.Entities, already covers the
meaningful behavior. If an extractor also tags individual fields with their own provenance (via
ContextFabric.SetFieldChain, covered in Context Fabric), expose a
public void ProcessEntity(EntityRef entity) method that calls EmitEntity plus the extra
tagging, so a test can call it directly without wiring ToolInvocationPipeline or any backend
bridge.
Where this fits
Section titled “Where this fits”ReadResult and EntityRef are covered in full in Tool Envelopes;
what ContextFabric does with the entities a ContextExtractor emits — merging, field-level
provenance, and how inferred values later reconcile against them — is covered in
Context Fabric. Once an entity a read tool surfaced is referenced by
a later write, see Authoring Write Tools for how a
WriteProposal is built and Affidavits & Provenance for
how that write’s fields carry provenance of their own.