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

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 for ToolError only when the query itself failed.
  • A fresh scope per invocation is how a plugin reaches a Scoped dependency at all. A plugin registered with Plugins.AddFromType<T>() — the path this page uses and the one the framework’s own guide teaches — is a process-wide singleton, so IServiceScopeFactory is the rule for any dependency of such a plugin that the host registers Scoped, not a variation on a theme. Semantic Kernel itself is not the constraint: a host that registers a KernelPlugin of its own per scope (KernelPluginFactory.CreateFromType<T>(null, sp)) on a Kernel resolved from that same scope does get a fresh Scoped dependency per turn. The anti-pattern below is about the AddFromType path.
  • Retryable on a ToolError a tool returns is advisory. The framework’s one automatic retry is ToolErrorFilter’s, and it fires only for an error that filter mapped from an exception it caught at a seam whose next() is the tool body — never for an error a tool hands back as its own result. That one becomes the tool’s visible result; its Retryable value is recorded as a tag on the affiant.tool_error telemetry event and nothing re-invokes anything.
  • .ToJsonString(), from Affiant.Abstractions.Models.ToolEnvelopeExtensions, is how every tool return gets to the wire. At 1.0.0-beta.3 it serializes through AffiantJson.SerializerOptions, the one set of conventions every Affiant envelope is written under: camelCase property names, enums as strings in the casing each schema freezes (ProvenanceSource PascalCase, ReviewStatus camelCase), one ISO spelling for every instant, and a null written as null rather than omitted — save on the few schema-optional properties that carry their own [JsonIgnore(WhenWritingNull)] — plus the kind discriminator ($type before 1.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.

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.

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.

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.