Agent Framework tool isolation matters when several .NET agents share a function-invoking IChatClient. In a local comparison, Microsoft.Agents.AI 1.21.0 allowed a call to another agent’s tool even though that name was absent from the calling agent’s advertised tools. Version 1.22.0 prevented that invocation in the same test. Evaluate the fixed package with an invocation test, and keep agent-specific tools out of shared AdditionalTools state.
The reproduction uses a deterministic fake chat client and harmless counters, so it needs no model endpoint or credentials. It tests what the function-invocation pipeline will execute when a response names an unadvertised tool; it does not measure how often a real model returns such a call or establish a complete authorization boundary.
Table of Contents
Test Agent Framework tool isolation
Start with a console project targeting net11.0 on SDK 11.0.100-rc.1.26425.128, the prerelease runtime used for this comparison. Replace the project file with the version-selectable configuration below, then replace Program.cs with the probe.
dotnet new console --framework net11.0 -n AgentToolProbe
cd AgentToolProbe
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AgentVersion Condition="'$(AgentVersion)' == ''">1.22.0</AgentVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI" Version="$(AgentVersion)" />
</ItemGroup>
</Project>
The default is the tested fixed version, 1.22.0. The AgentVersion property lets the same source build against 1.21.0 for a controlled comparison; 1.22.0 is not being presented as the latest release. Keep this project separate from an application upgrade until its package graph and behavior have been reviewed.
using System.Runtime.CompilerServices;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
bool normal = args.Contains("--normal");
bool inject = args.Contains("--inject-shared");
using var shared = new ProbeClient(normal)
.AsBuilder().UseFunctionInvocation().Build();
int publicCalls = 0, privilegedCalls = 0;
var publicTool = AIFunctionFactory.Create(
() => { Interlocked.Increment(ref publicCalls); return "public"; },
name: "public_read");
var privilegedTool = AIFunctionFactory.Create(
() => { Interlocked.Increment(ref privilegedCalls); return "privileged"; },
name: "privileged_write");
var publicAgent = shared.AsAIAgent(tools: [publicTool]);
var privilegedAgent = shared.AsAIAgent(tools: [privilegedTool]);
if (inject)
shared.GetService<FunctionInvokingChatClient>()!
.AdditionalTools = [privilegedTool];
await publicAgent.RunAsync("PUBLIC");
Console.WriteLine(
$"AgentAssembly={typeof(ChatClientAgent).Assembly.GetName().Version}");
Console.WriteLine($"public={publicCalls}; privileged={privilegedCalls}");
sealed class ProbeClient(bool normal) : IChatClient
{
public object? GetService(Type type, object? key = null) =>
key is null && type.IsInstanceOfType(this) ? this : null;
public void Dispose() { }
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
if (messages.Any(m => m.Role == ChatRole.Tool))
return Task.FromResult(new ChatResponse(
new ChatMessage(ChatRole.Assistant, "done")));
Console.WriteLine("Advertised=" +
string.Join(",", (options?.Tools ?? []).Select(t => t.Name)));
string requested = normal ? "public_read" : "privileged_write";
var call = new FunctionCallContent(
"probe-1", requested, new Dictionary<string, object?>());
return Task.FromResult(new ChatResponse(
new ChatMessage(ChatRole.Assistant, [call])));
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var response = await GetResponseAsync(
messages, options, cancellationToken);
foreach (var message in response.Messages)
yield return new ChatResponseUpdate
{ Role = message.Role, Contents = message.Contents };
}
}
Both agents share the same function-invoking pipeline but receive different tools. The fake client prints the public agent’s advertised names, then asks that agent to invoke privileged_write. That function only increments a local counter; no privileged operation is performed.
The second agent is intentionally constructed last. That order exposes the old shared lookup state in this minimal case, while the full matrix below also checks the reverse order. Returning a tool-result message ends the probe loop, and the streaming adapter satisfies the client interface; this particular call uses RunAsync.
dotnet run -p:AgentVersion=1.21.0
dotnet run -p:AgentVersion=1.22.0
For both runs, the advertised list should contain only public_read. In the tested 1.21.0 run, the counter output is public=0; privileged=1; in 1.22.0, it is public=0; privileged=0. The assembly line must change from 1.21.0.0 to 1.22.0.0, confirming that the comparison did not accidentally run the same package twice.
Check invocation as well as the tool schema
A correct outgoing tool list only shows which names the model was offered. The incoming response still contains a name that the function middleware must resolve. The old-package result demonstrates why inspecting ChatOptions.Tools alone can miss a registration problem: the schema stays scoped while another lookup path makes an additional function invocable.
The upstream fix in PR #8531 removes agent-tool assignment to the shared FunctionInvokingChatClient.AdditionalTools collection and retains the tools in the per-agent/per-run options path. It was included in the September 18, 2026 release of 1.22.0. The local package comparison verifies that change through execution.
Add an ordinary-call control and a deliberately contaminated shared-lookup control. A successful ordinary call confirms that tool invocation is active; the contaminated control confirms that a zero counter in the fixed case reflects isolation rather than a detector that never invokes anything.
dotnet run -p:AgentVersion=1.21.0 -- --normal
dotnet run -p:AgentVersion=1.22.0 -- --normal
dotnet run -p:AgentVersion=1.21.0 -- --inject-shared
dotnet run -p:AgentVersion=1.22.0 -- --inject-shared
The ordinary control should report public=1; privileged=0 on both versions. The injected shared-tool control should report public=0; privileged=1 on both, because the sample explicitly adds that tool back to the shared lookup. This is a test-only mutation that demonstrates the registration boundary; it should never be carried into application configuration.
What the full version matrix established
The independent full proof executed 24 conditions per package, for 48 rows in total. Each scenario ran with streaming and non-streaming responses, sequential and overlapping requests, and both agent construction orders. In the concurrent cases, a barrier held the first requests until both agents had entered the fake client.
| Scenario | 1.21.0 | 1.22.0 |
|---|---|---|
| Each agent requests its advertised tool | 8/8 isolated | 8/8 isolated |
| Each agent requests the other agent’s tool | 8/8 cross-agent invocation detected | 8/8 attempts not invoked |
| Deliberately inject privileged tool into shared lookup | 8/8 detections | 8/8 detections |
The results record both the advertised names and the actual function counters. In the unadvertised-call scenario, reversing construction changes which shared tool remains available on the old version; the matrix therefore checks isolation for both directions rather than assuming that one named agent always leaks.
An initial schema-only experiment showed correct names on both versions and could not distinguish them. Changing the probe to return an unadvertised function call exposed the actual version difference. The injected controls are additional detector checks, separate from the unmodified old/new comparison; they are not used as evidence that the original defect exists.
Both package runs resolved Microsoft.Extensions.AI 10.10.0. The result is bounded to those package versions, that common dependency, the tested .NET 11 RC1 runtime and the local fake client. It does not cover every provider, hosted agent, middleware configuration or later framework release.
Upgrade without rebuilding shared tool leakage
Inspect the construction path that combines UseFunctionInvocation, AsAIAgent or the equivalent agent factory and multiple tool sets. Review registrations of AdditionalTools and mutable options held by shared services, including startup code and application middleware. A package update does not remove an application’s deliberate shared registration, as the positive control demonstrates.
If an immediate package upgrade is blocked, separating each agent’s function-invoking wrapper is a reasonable mitigation to evaluate. Sharing a lower-level provider client may still be desirable, but wrapper ownership, disposal and provider behavior need their own test. That topology was not verified by this matrix, so do not substitute it for a demonstrated passing invocation test.
In your application regression test, request a harmless canary tool outside the calling agent’s configured set and assert that its callback count stays zero. Run the ordinary and deliberately contaminated controls too, then repeat the check through your actual streaming path and concurrent workflow. Capture the resolved package graph and runtime with the result so a future dependency change can be diagnosed.
Keep provider exceptions and unknown-tool outcomes observable, but avoid logging sensitive prompts or function arguments by default. Reject an unexpected tool call according to your application’s error policy rather than retrying it through a more permissive client. For state-changing tools, also enforce user and resource authorization at the operation itself; a scoped tool catalogue does not establish that the current user may perform the action.
For actions requiring human approval, the related C# tool-approval timeout and decision-persistence workflow covers that separate control. Apply approval and authorization requirements after validating the registration boundary; neither a safe counter result nor a model-visible schema replaces them.
Roll out the package change with the invocation test enabled, retain a known passing deployment configuration, and review the other release changes before upgrading an application. If a rollback restores 1.21.0, this matrix says the old shared-wrapper construction remains unsafe for the tested cross-agent calls. A rollback plan therefore needs a separately verified isolation design rather than relying on a version downgrade alone.
References
- Microsoft Agent Framework PR #8531: pass agent tools per run
- Microsoft Agent Framework .NET 1.22.0 release
- Agent Framework 1.22.0: tagged chat-client construction source
Demo source code: View the complete runnable example on GitHub.
Found this useful? Support more practical developer content.