Chapter 06 · Systems

Tools & Keyed DI

Tools are the agent's hands. A skill says "I need file_system" and the harness resolves an implementation, wraps it in safety guarantees, and presents it to the LLM as a callable function. The pattern is more interesting than it looks — let's open the hood.

The ITool interface

Every internal tool implements one interface. It looks roughly like this:

C# · ITool
public interface ITool
{
    string Name { get; }                                  // "file_system"
    string Description { get; }                           // "Reads files from disk under sandbox"
    IReadOnlyList<string> SupportedOperations { get; }    // ["read", "list", "write"]

    // Concurrency + risk metadata — all defaulted, override only when it differs:
    bool IsReadOnly => false;                             // read-only tools can batch in parallel
    bool IsConcurrencySafe => false;                      // fail-closed: assume not safe
    BlastRadius RiskTier => BlastRadius.Medium;           // feeds the graded-autonomy gate
    ToolOutputCategory? OutputCategory => null;           // null ⇒ sniff the output at runtime
    int? CompressionTokenThreshold => null;               // null ⇒ use the global default

    Task<ToolResult> ExecuteAsync(
        string operation,
        IReadOnlyDictionary<string, object?> parameters,
        CancellationToken cancellationToken = default);
}

One tool can expose multiple operations. file_system is a single ITool with operations read, list, write. This keeps the tool surface focused — the LLM sees one entry "file system" rather than three separate ones.

Keyed DI — the heart of the pattern

Tools are registered as keyed singletons in Infrastructure.AI/DependencyInjection.Tools.cs. Each tool exposes its key as a ToolName constant, and each registration is a factory lambda so the tool can pull its own collaborators from the container:

C# · DependencyInjection.Tools.cs
services.AddKeyedSingleton<ITool>(FileSystemTool.ToolName, (sp, _) =>
    new FileSystemTool(sp.GetRequiredService<IFileSystemService>()));

services.AddKeyedSingleton<ITool>(EchoCalculateTool.ToolName, (_, _) => new EchoCalculateTool());

services.AddKeyedSingleton<ITool>(DocumentSearchTool.ToolName, (sp, _) =>
    new DocumentSearchTool(sp.GetRequiredService<IRagOrchestrator>()));

services.AddKeyedSingleton<ITool>(RestrictedSearchTool.ToolName, (sp, _) =>
    new RestrictedSearchTool(/* ... */));
// ...

The real built-in tools are FileSystemTool (file_system), EchoCalculateTool (echo_calculate), RestrictedSearchTool (restricted_search), DocumentSearchTool (document_search), and the generative-UI render tools (Chapter 16). There is no CalculationTool or WebSearchTool — those names are historical.

Keyed DI, slowly

Normal dependency injection: you register one implementation per interface. When any class asks for that interface, the container hands back the registered class.

Example: services.AddSingleton<ILogger, FileLogger>(). Any class with an ILogger constructor parameter gets a FileLogger.

Keyed DI (added in .NET 8) lets you register multiple implementations of the same interface, each tagged with a string key. Callers ask for a specific one by key: sp.GetKeyedService<ITool>("file_system").

Why this matters for tools: a skill says "I need the tool named file_system" — as a plain string in a Markdown file. The harness can resolve that string to a concrete ITool at runtime, exactly because the tool is registered under that string key. Without keyed DI, you'd need ten different interfaces (IFileSystemTool, ICalculationTool...) or a giant switch statement. Keyed DI replaces both.

When AgentFactory processes a skill's allowed-tools list, it does exactly:

C# · AgentFactory.cs
foreach (var toolName in skill.AllowedTools)
{
    var tool = serviceProvider.GetRequiredKeyedService<ITool>(toolName);
    var aiTool = _toolConverter.Convert(tool, skill.AllowedOperations(toolName));
    aiTools.Add(aiTool);
}

From ITool to AITool

The LLM doesn't know about ITool. It speaks the Microsoft.Extensions.AI.AITool contract — a JSON-schema'd function with a callback. The bridge is AIToolConverter:

C# · AIToolConverter (excerpted)
var aiFunction = AIFunctionFactory.Create(
    async (string operation, JsonElement? parametersJson, CancellationToken ct) =>
    {
        if (!activeOperations.Contains(operation, StringComparer.OrdinalIgnoreCase))
            return $"Error: Operation '{operation}' is not available.";

        var parameters = ParseParameters(parametersJson);
        var result = await tool.ExecuteAsync(operation, parameters, ct);
        return result.Success ? result.Output ?? "OK" : $"Error: {result.Error}";
    },
    new AIFunctionFactoryOptions
    {
        Name = tool.Name,
        Description = description    // built from purpose + operations + param hints
    });

The LLM sees a function called e.g. file_system that takes two parameters: operation (one of the supported ops) and parametersJson (a JSON object of operation-specific arguments). When the LLM calls it, the converter routes to tool.ExecuteAsync.

Why two parameters and not a rich schema?

AIToolConverter is a generic fallback. Its priority is 200 — the lowest. For tools that benefit from richer per-operation schemas, you can register a tool-specific converter at priority 100, and it'll be preferred. The fallback "two params + JSON" pattern keeps the surface uniform and tools self-contained — they document their operations in SupportedOperations rather than relying on bespoke schema code.

Operation filtering by skill

A skill can declare not just which tools it can use but which operations within each tool. The converter respects this — only operations in the intersection of skill.allowed-tools and tool.SupportedOperations are exposed to the LLM. This is how a "read-only research" skill prevents the agent from invoking file_system.write.

The file system sandbox — case study

FileSystemTool (in Infrastructure.AI/Tools/) is the ITool the agent calls; it delegates to FileSystemService, which implements IFileSystemService and is the canonical example of why this architecture matters. Every operation goes through a path validator:

C# · sandbox check
private bool IsPathAllowed(string requestedPath)
{
    var absolute = Path.GetFullPath(requestedPath);
    return _allowedBasePaths.Any(allowed =>
        absolute.StartsWith(Path.GetFullPath(allowed), StringComparison.OrdinalIgnoreCase));
}

Allowed paths come from AppConfig.Infrastructure.FileSystem.AllowedBasePaths. Path traversal attacks (../../etc/passwd) are caught because Path.GetFullPath normalizes before comparison.

!
The sandbox is the LLM's only restraint

The agent's reasoning can be tricked, prompted, or jailbroken. The sandbox can't — it's a deterministic check in code. Never disable it just to make a feature work. Add the specific path you need to AllowedBasePaths and document why.

MCP tools join the same tool surface

Tools discovered on external MCP servers do not go through ITool. McpToolProvider casts the SDK's McpClientTool straight to Microsoft.Extensions.AI.AITool and hands the list to the agent — no wrapper class, no per-tool keyed DI. Once converted they sit alongside the internal tools in ChatOptions.Tools, so the agent doesn't know or care that github_create_issue lives on a remote MCP server. Both internal (IToolAITool) and MCP (McpClientToolAITool) tools are then wrapped by GovernedAIFunction (see below), so governance applies uniformly regardless of source. See MCP Server & Client for the discovery flow.

Invocation-time governance — GovernedAIFunction

Every converted tool — internal, MCP, or plugin-provided — is wrapped one more time by GovernedAIFunction (Application.AI.Common/Services/Tools/). It derives from DelegatingAIFunction, so the tool's name, description, and JSON schema pass through unchanged; only the invocation is intercepted. This is the single chokepoint for the agent's autonomous tool calls. On each call, InvokeCoreAsync runs three ambient gates in order, then executes, then optionally scrubs:

#GateWhat it does
1IToolInvocationGovernorAuthorization. Chains permission resolution → graded-autonomy risk gate → capability enforcement → YAML policy. A denial returns the governor's model-facing message; the tool never runs. Opt-in via GovernanceConfig.EnforceToolInvocation.
2IToolClassificationGatePurview data-classification DLP. A Block verdict returns a message instead of running; a RedactOutput verdict lets it run then scrubs the result. Opt-in via AppConfig:AI:Governance:DataClassification:Mode (Off/Audit/Enforce).
3IProgressEvaluatorSpin / no-progress guard. Halts a call when the agent is repeating identical invocations without progress.
!
This is the live tool-gating path — not a MediatR behavior

Older drafts of this guide described two MediatR pipeline behaviors — ToolPermissionBehavior and GovernancePolicyBehavior — as the gating pipeline. Both were deleted in PR #90. They never fired in production: nothing implemented the IToolRequest marker they keyed on. Tool gating happens on the execution path via GovernedAIFunction, as above. All three gates are inert unless ambient and opt-in enabled (closed-by-default).

Risk tiers & blast radius

Each ITool declares its intrinsic RiskTier — a BlastRadius value (Trivial, Low, Medium, High, Critical) describing how much damage a single call can do. The default is Medium, but tools should override it: a read-only lookup as Trivial/Low, a tool that runs commands or touches production state as High/Critical. The tier flows straight into the graded-autonomy engine (gate 1 above) — higher autonomy tiers may auto-approve low-radius tools while still requiring human approval for high-radius ones — and it sets the severity when a call escalates. IToolRiskClassifier / ToolRiskClassifier resolve the effective tier.

Local plugin system

Beyond built-in tools and MCP-discovered tools, the harness supports local plugins — external directories that bundle their own skills and MCP servers. Declare plugins in appsettings.json:

json · appsettings.json (excerpt)
"Plugins": {
  "Declarations": [
    {
      "Id": "code-analysis",
      "Path": "./plugins/code-analysis",
      "AllowedTools": ["file_system", "ast_parser"],
      "DeniedTools": ["file_system_write"],
      "AutonomyLevel": "Supervised"
    }
  ]
}

Each plugin directory contains a plugin.json manifest that lists its skills and MCP server endpoints. At startup, IPluginLoader reads the manifest, IPluginManifestReader parses it, and IPluginRegistry tracks the loaded plugins. Skills from plugins are loaded as Injected mode — they receive all MCP tools from their plugin's servers automatically.

!
Plugin boundary governance

AllowedTools restricts which tools the plugin's skills can access. DeniedTools is stronger — it's bypass-immune, meaning even auto-approve modes and Autonomous autonomy tiers cannot override it. Use DeniedTools for hard security boundaries (e.g. preventing a third-party plugin from writing files). AutonomyLevel sets the permission tier for the plugin's tool calls. See Observability & Safety for how this integrates with the permission resolver.

Generative-UI render tools

One special tool family lets the agent render inline UI in a connected browser instead of returning text: render_image, render_form, render_table, and render_chart. They share a base class (SingleRenderProxyTool) and a client round-trip bridge, and they're deduplicated so the model sees one tool per widget. The full flow — backend tools, the frontend {render, validate, ack} registry, widget persistence, and the acting Dashboard agent — is its own chapter. See Chapter 16 · Generative UI & Widgets.

Tool output compression

Tools can return large outputs — a diagnostic dump, a JSON API response, or a verbose XML document. Sending all of this to the LLM wastes tokens and can exceed context limits. The ToolOutputCompressionBehavior MediatR pipeline behavior solves this by compressing tool outputs before they reach the agent.

How it works: each tool declares an OutputCategory (of type ToolOutputCategory), or leaves it null and lets ContentTypeDetector sniff the output at runtime. Based on the category, a strategy is selected:

CategoryWhat it does
JsonStructural compression of JSON — removes redundant keys, collapses arrays, preserves schema
FileContentTrims large file dumps, keeping the head/tail and structurally significant regions
SearchResultsKeeps the top-ranked hits and prunes low-signal result padding
TabularCompresses row-heavy tabular output while preserving the column shape
FreeTextSentence-boundary truncation with optional LLM summarization fallback

Compression only triggers when the output exceeds a token threshold — the global ToolOutputCompressionConfig.DefaultTokenThreshold (default 2000 tokens, under AppConfig.AI.ToolOutputCompression), or a per-tool override via ITool.CompressionTokenThreshold. Below that, tool output passes through unchanged. When LlmFallbackEnabled is set, an economy-tier model summarizes FreeText that heuristics can't shrink enough.

Where each tool lives in the repo

Tool familyProject
File system, echo, restricted-search, generative-UI render toolsInfrastructure.AI/Tools/
RAG retrieval tool (document_search)Infrastructure.AI/Tools/DocumentSearchTool.cs
MCP-discovered toolsCast to AITool by McpToolProvider (not ITool)
Plugin-provided toolsPlugin's local directory; loaded via IPluginLoader
Tool output compressionToolOutputCompressionBehavior (MediatR pipeline behavior)
Tool DI registrationInfrastructure.AI/DependencyInjection.Tools.cs
Invocation-time governance wrapperApplication.AI.Common/Services/Tools/GovernedAIFunction.cs

Observability

Every tool invocation is wrapped in an OpenTelemetry span by the chat client middleware. The span includes the tool name, the operation, latency, and success/failure. ToolExecutionMetrics also emits a Counter per invocation. When an agent goes off the rails, the first place to look is the tool-call sequence in a Jaeger trace — see Observability & Safety.


Where to go from here