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. Every [KernelFunction]-decorated method Semantic Kernel invokes
must return Task<string> — that’s Semantic Kernel’s own calling convention — but what a read
tool puts inside that string 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, meant
to be read directly by a human or a chat UI. 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 forward into later conversation turns. 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.
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.SemanticKernel;
public class SearchBooksPlugin(LibraryDbContext dbContext){ [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 { // 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 — field keys must match what an IFieldMapper<T> // expects to find later, if this entity is ever the subject of a write. 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. .ToJsonString(), fromAffiant.Abstractions.Models.ToolEnvelopeExtensions, is how every tool return gets to the wire — camelCase properties, plus the$typediscriminator that makes polymorphic deserialization possible downstream.
The [Book:id] reference
Section titled “The [Book:id] reference”Rule 2 of the Seven Normative Rules asks read tools to embed a
stable, quotable identifier for anything the markdown names — in the framework specification’s
shorthand, [entity:id]. The worked example above does this literally: each row’s Book column
carries [Book:{book.BookId}] ahead of the title. 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:
builder.Services.AddAffiantReadTool("SearchBooks", entityType: "Book");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. See Quickstart for the equivalent registration step on the
write side, AddAffiantTool<TStrategy>.
A variation: singleton plugins and IServiceScopeFactory
Section titled “A variation: singleton plugins and IServiceScopeFactory”If a plugin is registered as a singleton rather than scoped — a catalog shared across the whole
process rather than per request, say — injecting a scoped DbContext directly into its
constructor throws at startup. Inject IServiceScopeFactory instead and create a scope per
invocation:
public class CatalogPlugin(IServiceScopeFactory scopeFactory){ public async Task<string> SearchBooks(/* ... */) { // DbContext is scoped; create a fresh scope per invocation to avoid sharing // a disposed context across concurrent requests. using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<LibraryDbContext>(); // ... rest of the query, same as above }}IServiceScopeFactory is registered automatically by the host — you don’t need to add it
yourself.
Context extraction: carrying entities across turns
Section titled “Context extraction: carrying entities across turns”A read tool’s Entities are only useful to a later turn if 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 remembering:
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 does the undifferentiated work: it lets the wrapped function run first, checks
MatchesTool against the invoked function’s name, deserializes the JSON result as a
ToolEnvelope, and — only if it comes back as a ReadResult with a non-empty Entities array —
calls your ExtractAsync. A subclass’s job is the domain-specific two lines:
using Affiant.Abstractions.Models;using Affiant.Core.Filters;using Affiant.Core.Services;using Microsoft.Extensions.Logging;using Microsoft.SemanticKernel;
public class BookSearchExtractor( ContextFabric contextFabric, ILogger<BookSearchExtractor> logger) : ContextExtractor(contextFabric, logger){ // OrdinalIgnoreCase matches Semantic Kernel's own tool-name comparison. protected override bool MatchesTool(string toolName) => toolName.Equals("SearchBooks", StringComparison.OrdinalIgnoreCase);
protected override Task ExtractAsync(ReadResult result, FunctionInvocationContext context) { foreach (var entity in result.Entities) EmitEntity(entity); return Task.CompletedTask; }}Register it the same way you’d register any Semantic Kernel filter:
builder.Services.AddScoped<IFunctionInvocationFilter, BookSearchExtractor>();EmitEntity calls ContextFabric.Upsert and logs at debug level — a subclass never touches
ContextFabric or parses JSON directly. Not every read tool needs one: a tool with nothing
worth remembering (a “what’s today’s date?” query) legitimately returns an empty Entities
array, and there’s nothing for an extractor to do with it.
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 ProcessEntity(EntityRef entity) method that calls EmitEntity plus the extra tagging, so
a test can call it directly without wiring the full Semantic Kernel filter pipeline.
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.