Skip to content

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.

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 for ToolError only when the query itself failed.
  • .ToJsonString(), from Affiant.Abstractions.Models.ToolEnvelopeExtensions, is how every tool return gets to the wire — camelCase properties, plus the $type discriminator that makes polymorphic deserialization possible downstream.

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.

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.

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.