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:
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:
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.
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:
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:
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.
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:
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 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
(ITool→AITool) and MCP (McpClientTool→AITool)
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:
| # | Gate | What it does |
|---|---|---|
| 1 | IToolInvocationGovernor | Authorization. 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. |
| 2 | IToolClassificationGate | Purview 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). |
| 3 | IProgressEvaluator | Spin / no-progress guard. Halts a call when the agent is repeating identical invocations without progress. |
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:
"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.
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:
| Category | What it does |
|---|---|
Json | Structural compression of JSON — removes redundant keys, collapses arrays, preserves schema |
FileContent | Trims large file dumps, keeping the head/tail and structurally significant regions |
SearchResults | Keeps the top-ranked hits and prunes low-signal result padding |
Tabular | Compresses row-heavy tabular output while preserving the column shape |
FreeText | Sentence-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 family | Project |
|---|---|
| File system, echo, restricted-search, generative-UI render tools | Infrastructure.AI/Tools/ |
RAG retrieval tool (document_search) | Infrastructure.AI/Tools/DocumentSearchTool.cs |
| MCP-discovered tools | Cast to AITool by McpToolProvider (not ITool) |
| Plugin-provided tools | Plugin's local directory; loaded via IPluginLoader |
| Tool output compression | ToolOutputCompressionBehavior (MediatR pipeline behavior) |
| Tool DI registration | Infrastructure.AI/DependencyInjection.Tools.cs |
| Invocation-time governance wrapper | Application.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.