diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj index 802c864c1f..53fd4757ee 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -13,7 +13,7 @@ - + @@ -37,4 +37,4 @@ - + \ No newline at end of file diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Custom/CustomAITools.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Custom/CustomAITools.cs new file mode 100644 index 0000000000..d3deb9162c --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Custom/CustomAITools.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace AgentWebChat.AgentHost.Custom; + +public class CustomAITool : AITool +{ +} + +public class CustomFunctionTool : AIFunction +{ + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return new ValueTask(arguments.Context?.Count ?? 0); + } +} diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index ac905d3aa1..cb1a7e3cd9 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -2,6 +2,7 @@ using A2A.AspNetCore; using AgentWebChat.AgentHost; +using AgentWebChat.AgentHost.Custom; using AgentWebChat.AgentHost.Utilities; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; @@ -25,6 +26,8 @@ var pirateAgentBuilder = builder.AddAIAgent( instructions: "You are a pirate. Speak like a pirate", description: "An agent that speaks like a pirate.", chatClientServiceKey: "chat-model") + .WithAITool(new CustomAITool()) + .WithAITool(new CustomFunctionTool()) .WithInMemoryThreadStore(); var knightsKnavesAgentBuilder = builder.AddAIAgent("knights-and-knaves", (sp, key) => diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs index 3c5a5b84c2..d958fc3578 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Linq; using Microsoft.Agents.AI.Hosting.Local; using Microsoft.Extensions.AI; @@ -29,7 +30,8 @@ public static class AgentHostingServiceCollectionExtensions return services.AddAIAgent(name, (sp, key) => { var chatClient = sp.GetRequiredService(); - return new ChatClientAgent(chatClient, instructions, key); + var tools = GetRegisteredToolsForAgent(sp, name); + return new ChatClientAgent(chatClient, instructions, key, tools: tools); }); } @@ -46,7 +48,11 @@ public static class AgentHostingServiceCollectionExtensions { Throw.IfNull(services); Throw.IfNullOrEmpty(name); - return services.AddAIAgent(name, (sp, key) => new ChatClientAgent(chatClient, instructions, key)); + return services.AddAIAgent(name, (sp, key) => + { + var tools = GetRegisteredToolsForAgent(sp, name); + return new ChatClientAgent(chatClient, instructions, key, tools: tools); + }); } /// @@ -65,7 +71,8 @@ public static class AgentHostingServiceCollectionExtensions return services.AddAIAgent(name, (sp, key) => { var chatClient = chatClientServiceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientServiceKey); - return new ChatClientAgent(chatClient, instructions, key); + var tools = GetRegisteredToolsForAgent(sp, name); + return new ChatClientAgent(chatClient, instructions, key, tools: tools); }); } @@ -86,7 +93,8 @@ public static class AgentHostingServiceCollectionExtensions return services.AddAIAgent(name, (sp, key) => { var chatClient = chatClientServiceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientServiceKey); - return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description); + var tools = GetRegisteredToolsForAgent(sp, name); + return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description, tools: tools); }); } @@ -142,4 +150,10 @@ public static class AgentHostingServiceCollectionExtensions services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext)); services.AddSingleton(); } + + private static IList GetRegisteredToolsForAgent(IServiceProvider serviceProvider, string agentName) + { + var registry = serviceProvider.GetService(); + return registry?.GetTools(agentName) ?? []; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs index 902c54ebe9..e8a55b3baa 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Linq; +using Microsoft.Agents.AI.Hosting.Local; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Shared.Diagnostics; @@ -59,4 +62,52 @@ public static class HostedAgentBuilderExtensions }); return builder; } + + /// + /// Adds an AI tool to an agent being configured with the service collection. + /// + /// The hosted agent builder. + /// The AI tool to add to the agent. + /// The same instance so that additional calls can be chained. + /// Thrown when or is . + public static IHostedAgentBuilder WithAITool(this IHostedAgentBuilder builder, AITool tool) + { + Throw.IfNull(builder); + Throw.IfNull(tool); + + var agentName = builder.Name; + var services = builder.ServiceCollection; + + // Get or create the agent tool registry + var descriptor = services.FirstOrDefault(sd => !sd.IsKeyedService && sd.ServiceType.Equals(typeof(LocalAgentToolRegistry))); + if (descriptor?.ImplementationInstance is not LocalAgentToolRegistry toolRegistry) + { + toolRegistry = new(); + services.Add(ServiceDescriptor.Singleton(toolRegistry)); + } + + toolRegistry.AddTool(agentName, tool); + + return builder; + } + + /// + /// Adds multiple AI tools to an agent being configured with the service collection. + /// + /// The hosted agent builder. + /// The collection of AI tools to add to the agent. + /// The same instance so that additional calls can be chained. + /// Thrown when or is . + public static IHostedAgentBuilder WithAITools(this IHostedAgentBuilder builder, params AITool[] tools) + { + Throw.IfNull(builder); + Throw.IfNull(tools); + + foreach (var tool in tools) + { + builder.WithAITool(tool); + } + + return builder; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentToolRegistry.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentToolRegistry.cs new file mode 100644 index 0000000000..ea8d8ad74e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentToolRegistry.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Local; + +internal sealed class LocalAgentToolRegistry +{ + private readonly Dictionary> _toolsByAgentName = new(); + + public void AddTool(string agentName, AITool tool) + { + if (!this._toolsByAgentName.TryGetValue(agentName, out var tools)) + { + tools = []; + this._toolsByAgentName[agentName] = tools; + } + + tools.Add(tool); + } + + public IList GetTools(string agentName) + { + return this._toolsByAgentName.TryGetValue(agentName, out var tools) ? tools : []; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index 59345d21ae..e3d7f00aa1 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -31,8 +31,10 @@ - + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs new file mode 100644 index 0000000000..9993007de1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for AI tool registration extensions on . +/// +public sealed class HostedAgentBuilderToolsExtensionsTests +{ + [Fact] + public void WithAITool_ThrowsWhenBuilderIsNull() + { + // Arrange + var tool = new DummyAITool(); + + // Act & Assert + Assert.Throws(() => HostedAgentBuilderExtensions.WithAITool(null!, tool)); + } + + [Fact] + public void WithAITool_ThrowsWhenToolIsNull() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddAIAgent("test-agent", "Test instructions"); + + // Act & Assert + Assert.Throws(() => builder.WithAITool(null!)); + } + + [Fact] + public void WithAITools_ThrowsWhenBuilderIsNull() + { + // Arrange + var tools = new[] { new DummyAITool() }; + + // Act & Assert + Assert.Throws(() => HostedAgentBuilderExtensions.WithAITools(null!, tools)); + } + + [Fact] + public void WithAITools_ThrowsWhenToolsArrayIsNull() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddAIAgent("test-agent", "Test instructions"); + + // Act & Assert + Assert.Throws(() => builder.WithAITools(null!)); + } + + [Fact] + public void RegisteredTools_ResolvesAllToolsForAgent() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new MockChatClient()); + + var builder = services.AddAIAgent("test-agent", "Test instructions"); + var tool1 = new DummyAITool(); + var tool2 = new DummyAITool(); + + builder + .WithAITool(tool1) + .WithAITool(tool2); + + var serviceProvider = services.BuildServiceProvider(); + + var agent1Tools = ResolveAgentTools(serviceProvider, "test-agent"); + Assert.Contains(tool1, agent1Tools); + Assert.Contains(tool2, agent1Tools); + } + + [Fact] + public void RegisteredTools_IsolatedPerAgent() + { + var services = new ServiceCollection(); + services.AddSingleton(new MockChatClient()); + + var builder1 = services.AddAIAgent("agent1", "Agent 1 instructions"); + var builder2 = services.AddAIAgent("agent2", "Agent 2 instructions"); + + var tool1 = new DummyAITool(); + var tool2 = new DummyAITool(); + var tool3 = new DummyAITool(); + + builder1 + .WithAITool(tool1) + .WithAITool(tool2); + + builder2 + .WithAITool(tool3); + + var serviceProvider = services.BuildServiceProvider(); + + var agent1Tools = ResolveAgentTools(serviceProvider, "agent1"); + var agent2Tools = ResolveAgentTools(serviceProvider, "agent2"); + + Assert.Contains(tool1, agent1Tools); + Assert.Contains(tool2, agent1Tools); + Assert.Contains(tool3, agent2Tools); + } + + private static IList ResolveAgentTools(IServiceProvider serviceProvider, string name) + { + var agent = serviceProvider.GetRequiredKeyedService(name) as ChatClientAgent; + Assert.NotNull(agent?.ChatOptions?.Tools); + return agent.ChatOptions.Tools; + } + + /// + /// Dummy AITool implementation for testing. + /// + private sealed class DummyAITool : AITool + { + } + + /// + /// Mock chat client for testing. + /// + private sealed class MockChatClient : IChatClient + { + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + throw new NotImplementedException(); + } + } +}