diff --git a/readme.md b/readme.md index c9a6fc7..8699f68 100644 --- a/readme.md +++ b/readme.md @@ -264,6 +264,15 @@ tools = ["get_date"] This enables a flexible and convenient mix of static and dynamic context for agents, all driven from configuration. +In addition to registering your own tools in DI, you can also use leverage the MCP C# SDK and reuse +the same tool declarations: + +```csharp +builder.Services.AddMcpServer().WithTools(); + +// 👇 Reuse same tool definitions in agents +builder.AddAIAgents().WithTools(); +``` diff --git a/sample/Server/NotesTools.cs b/sample/Server/NotesTools.cs new file mode 100644 index 0000000..f3931c3 --- /dev/null +++ b/sample/Server/NotesTools.cs @@ -0,0 +1,29 @@ +using Microsoft.Agents.AI; +using ModelContextProtocol.Server; + +public class NotesContextProvider(NotesTools notes) : AIContextProvider +{ + public override ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) + => ValueTask.FromResult(new AIContext + { + Instructions = + $""" + Your current state is: + + ${notes.GetNotes()} + + """ + }); +} + +[McpServerToolType] +public class NotesTools +{ + string notes = ""; + + [McpServerTool] + public string GetNotes() => notes; + + [McpServerTool] + public void SaveNotes(string notes) => this.notes = notes; +} diff --git a/sample/Server/Program.cs b/sample/Server/Program.cs index 8e89efd..9050a13 100644 --- a/sample/Server/Program.cs +++ b/sample/Server/Program.cs @@ -1,9 +1,12 @@ -using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; using Devlooped.Extensions.AI; +using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.OpenAI; using Microsoft.Extensions.AI; +using ModelContextProtocol.Server; using Spectre.Console; var builder = WebApplication.CreateBuilder(args); @@ -23,10 +26,15 @@ // dummy ones for illustration builder.Services.AddKeyedSingleton("create_order", AIFunctionFactory.Create(() => "OK", "create_order")); builder.Services.AddKeyedSingleton("cancel_order", AIFunctionFactory.Create(() => "OK", "cancel_order")); -builder.Services.AddKeyedSingleton("save_notes", AIFunctionFactory.Create((string notes) => true, "save_notes")); + +builder.Services.AddKeyedSingleton("notes"); + +// 👇 seamless integration of MCP tools +//builder.Services.AddMcpServer().WithTools(); // 👇 implicitly calls AddChatClients -builder.AddAIAgents(); +builder.AddAIAgents() + .WithTools(); var app = builder.Build(); @@ -76,4 +84,4 @@ }); } -app.Run(); +app.Run(); \ No newline at end of file diff --git a/sample/Server/Server.csproj b/sample/Server/Server.csproj index 978e7d6..5183465 100644 --- a/sample/Server/Server.csproj +++ b/sample/Server/Server.csproj @@ -2,10 +2,12 @@ net10.0 + true + diff --git a/sample/Server/notes.agent.md b/sample/Server/notes.agent.md index fe7ac9c..dd07c05 100644 --- a/sample/Server/notes.agent.md +++ b/sample/Server/notes.agent.md @@ -3,7 +3,30 @@ id: ai.agents.notes description: Provides free-form memory client: grok model: grok-4-fast -use: ["tone"] -tools: ["save_notes", "get_date"] +use: ["tone", "notes"] +tools: ["get_notes", "save_notes", "get_date"] --- -You organize and keep notes for the user, using JSON-LD \ No newline at end of file +You organize and keep notes for the user. + +You extract key points from the user's input and store them as notes in the agent +state. You keep track of notes that reference external files too, adding corresponding +notes relating to them if the user sends more information about them. + +You use JSON-LD format to store notes, optimized for easy retrieval, filtering +or reasoning later. This can involve nested structures, lists, tags, categories, timestamps, +inferred relationships between nodes and entities, etc. As you collect more notes, you can +go back and update, merge or reorganize existing notes to improve their structure, cohesion +and usefulness for retrieval and querying. + +When storing relative times (like "last week" or "next week"), always convert them to absolute +dates or timestamps, so you can be precise when responding about them. + +You are NOT a general-purpose assistant that can answer questions or perform tasks unrelated +to note-taking and recalling notes. If the user asks you to do something outside of +note-taking, you should politely decline and remind them of your purpose. + +Never include technical details about the JSON format or the storage mechanism in your +responses. Just focus on the content of the notes and how they can help the user. + +When recalling information from notes, don't ask for follow-up questions or request +any more information. Just provide the information. diff --git a/src/Agents/Agents.csproj b/src/Agents/Agents.csproj index c6c516b..d87faf5 100644 --- a/src/Agents/Agents.csproj +++ b/src/Agents/Agents.csproj @@ -17,18 +17,29 @@ $(NoWarn);CS0436;SYSLIB1100;SYSLIB1101;MEAI001 - + + - + + + + + + + + + + + - - + + diff --git a/src/Agents/CompositeAIContextProvider.cs b/src/Agents/CompositeAIContextProvider.cs index 9597c11..be42e3c 100644 --- a/src/Agents/CompositeAIContextProvider.cs +++ b/src/Agents/CompositeAIContextProvider.cs @@ -62,6 +62,9 @@ public override async ValueTask InvokingAsync(InvokingContext invokin if (staticContext is not null) return staticContext; + if (providers.Count == 1) + return await providers[0].InvokingAsync(invoking, cancellationToken); + var context = new AIContext(); var instructions = new List(); var messages = new List(); diff --git a/src/Agents/ConfigurableAIAgent.cs b/src/Agents/ConfigurableAIAgent.cs index eb71930..869d547 100644 --- a/src/Agents/ConfigurableAIAgent.cs +++ b/src/Agents/ConfigurableAIAgent.cs @@ -1,5 +1,9 @@ -using System.ComponentModel; +using System; +using System.ComponentModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reflection; using System.Text.Json; using Devlooped.Extensions.AI; using Devlooped.Extensions.AI.Grok; @@ -8,6 +12,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; namespace Devlooped.Agents.AI; @@ -134,21 +139,17 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum if (contextFactory is not null) { - if (options.Use?.Count > 0) - throw new InvalidOperationException($"Invalid simultaneous use of keyed service {nameof(AIContextProviderFactory)} and '{section}:use' in configuration."); + if (options.Use?.Count > 0 || options.Tools?.Count > 0) + throw new InvalidOperationException($"Invalid simultaneous use of keyed service {nameof(AIContextProviderFactory)} and '{section}:use/tools' in configuration."); options.AIContextProviderFactory = contextFactory.CreateProvider; } - else if (services.GetKeyedService(name) is { } contextProvider) - { - if (options.Use?.Count > 0) - throw new InvalidOperationException($"Invalid simultaneous use of keyed service {nameof(AIContextProvider)} and '{section}:use' in configuration."); - - options.AIContextProviderFactory = _ => contextProvider; - } - else if (options.Use?.Count > 0 || options.Tools?.Count > 0) + else { var contexts = new List(); + if (services.GetKeyedService(name) is { } contextProvider) + contexts.Add(contextProvider); + foreach (var use in options.Use ?? []) { if (services.GetKeyedService(use) is { } staticContext) @@ -196,7 +197,7 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum { var tool = services.GetKeyedService(toolName) ?? services.GetKeyedService(toolName) ?? - throw new InvalidOperationException($"Specified tool '{toolName}' for agent '{section}' is not registered as a keyed {nameof(AITool)} or {nameof(AIFunction)}."); + throw new InvalidOperationException($"Specified tool '{toolName}' for agent '{section}' is not registered as a keyed {nameof(AITool)}, {nameof(AIFunction)} or MCP server tools."); contexts.Add(new StaticAIContextProvider(new AIContext { Tools = [tool] })); } diff --git a/src/Agents/ConfigurableAgentsExtensions.cs b/src/Agents/ConfigurableAgentsExtensions.cs index 386b11c..0ab7c00 100644 --- a/src/Agents/ConfigurableAgentsExtensions.cs +++ b/src/Agents/ConfigurableAgentsExtensions.cs @@ -1,4 +1,7 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; using Devlooped.Agents.AI; using Devlooped.Extensions.AI; using Microsoft.Agents.AI; @@ -7,6 +10,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using ModelContextProtocol.Server; namespace Microsoft.Extensions.DependencyInjection; @@ -16,6 +20,52 @@ namespace Microsoft.Extensions.DependencyInjection; [EditorBrowsable(EditorBrowsableState.Never)] public static class ConfigurableAgentsExtensions { + /// Adds instances to the service collection backing . + /// The tool type. + /// The builder instance. + /// The serializer options governing tool parameter marshalling. + /// The builder provided in . + /// is . + /// + /// This method discovers all instance and static methods (public and non-public) on the specified + /// type, where the methods are attributed as , and adds an + /// instance for each. For instance methods, an instance will be constructed for each invocation of the tool. + /// + public static IAIAgentsBuilder WithTools<[DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.PublicConstructors)] TToolType>( + this IAIAgentsBuilder builder, + JsonSerializerOptions? serializerOptions = null, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + Throw.IfNull(builder); + + // Preserve existing registration if any, such as when using Devlooped.Extensions.DependencyInjection + // via [Service] attribute or by convention. + builder.Services.TryAdd(ServiceDescriptor.Describe(typeof(TToolType), typeof(TToolType), lifetime)); + + serializerOptions ??= ToolJsonOptions.Default; + + foreach (var toolMethod in typeof(TToolType).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)) + { + if (toolMethod.GetCustomAttribute() is { } toolAttribute) + { + var function = toolMethod.IsStatic + ? AIFunctionFactory.Create(toolMethod, null, toolAttribute.Name ?? ToolJsonOptions.Default.PropertyNamingPolicy!.ConvertName(toolMethod.Name)) + : AIFunctionFactory.Create(toolMethod, args => args.Services?.GetRequiredService(typeof(TToolType)) ?? + throw new InvalidOperationException("Could not determine target instance for tool."), + new AIFunctionFactoryOptions { Name = toolAttribute.Name ?? ToolJsonOptions.Default.PropertyNamingPolicy!.ConvertName(toolMethod.Name) }); + + builder.Services.TryAdd(ServiceDescriptor.DescribeKeyed( + typeof(AIFunction), function.Name, + (_, _) => function, lifetime)); + } + } + + return builder; + } + /// /// Adds AI agents to the host application builder based on configuration. /// @@ -24,8 +74,7 @@ public static class ConfigurableAgentsExtensions /// Optional action to configure options for each agent. /// The configuration prefix for agents, defaults to "ai:agents". /// The host application builder with AI agents added. - public static TBuilder AddAIAgents(this TBuilder builder, Action? configurePipeline = default, Action? configureOptions = default, string prefix = "ai:agents") - where TBuilder : IHostApplicationBuilder + public static IAIAgentsBuilder AddAIAgents(this IHostApplicationBuilder builder, Action? configurePipeline = default, Action? configureOptions = default, string prefix = "ai:agents") { builder.AddChatClients(); @@ -61,7 +110,7 @@ public static TBuilder AddAIAgents(this TBuilder builder, Action sp.GetRequiredKeyedService(name))); } - return builder; + return new DefaultAIAgentsBuilder(builder); } /// Gets an AI agent by name (case-insensitive) from the service provider. diff --git a/src/Agents/IAIAgentsBuilder.cs b/src/Agents/IAIAgentsBuilder.cs new file mode 100644 index 0000000..c44d02c --- /dev/null +++ b/src/Agents/IAIAgentsBuilder.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.Metrics; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Devlooped.Agents.AI; + +/// Provides a mechanism to configure AI agents. +public interface IAIAgentsBuilder : IHostApplicationBuilder +{ +} + +class DefaultAIAgentsBuilder(IHostApplicationBuilder builder) : IAIAgentsBuilder +{ + public IDictionary Properties => builder.Properties; + + public IConfigurationManager Configuration => builder.Configuration; + + public IHostEnvironment Environment => builder.Environment; + + public ILoggingBuilder Logging => builder.Logging; + + public IMetricsBuilder Metrics => builder.Metrics; + + public IServiceCollection Services => builder.Services; + + public void ConfigureContainer(IServiceProviderFactory factory, Action? configure = null) where TContainerBuilder : notnull => builder.ConfigureContainer(factory, configure); +} \ No newline at end of file diff --git a/src/Extensions/Extensions.csproj b/src/Extensions/Extensions.csproj index d430668..0928647 100644 --- a/src/Extensions/Extensions.csproj +++ b/src/Extensions/Extensions.csproj @@ -24,12 +24,22 @@ + + + + + - - + + + + + + + diff --git a/src/Tests/ConfigurableAgentTests.cs b/src/Tests/ConfigurableAgentTests.cs index 7d0edd0..7df9f2e 100644 --- a/src/Tests/ConfigurableAgentTests.cs +++ b/src/Tests/ConfigurableAgentTests.cs @@ -281,7 +281,8 @@ public void CanSetOpenAIReasoningAndVerbosity() ["ai:agents:bot:options:verbosity"] = "low", }); - var app = builder.AddAIAgents().Build(); + builder.AddAIAgents(); + var app = builder.Build(); var agent = app.Services.GetRequiredKeyedService("bot"); var options = agent.GetService(); @@ -304,7 +305,8 @@ public void CanSetGrokOptions() ["ai:agents:bot:options:search"] = "auto", }); - var app = builder.AddAIAgents().Build(); + builder.AddAIAgents(); + var app = builder.Build(); var agent = app.Services.GetRequiredKeyedService("bot"); var options = agent.GetService(); @@ -424,7 +426,7 @@ public void UseAndContextProviderFactoryIncompatible() } [Fact] - public void UseAndContextProviderIncompatible() + public async Task UseAndContextProviderCompositeAsync() { var builder = new HostApplicationBuilder(); @@ -448,13 +450,28 @@ public void UseAndContextProviderIncompatible() """ """"); - builder.Services.AddKeyedSingleton("chat", Mock.Of()); + var context = new AIContext { Instructions = "foo" }; + + var provider = new Mock(); + provider + .Setup(x => x.InvokingAsync(It.IsAny(), default(CancellationToken))) + .ReturnsAsync(context); + + builder.Services.AddKeyedSingleton("chat", provider.Object); builder.AddAIAgents(); var app = builder.Build(); - var exception = Assert.ThrowsAny(() => app.Services.GetRequiredKeyedService("chat")); + var agent = app.Services.GetRequiredKeyedService("chat"); - Assert.Contains("ai:agents:chat:use", exception.Message); + var options = agent.GetService(); + Assert.NotNull(options?.AIContextProviderFactory); + + var actualProvider = options?.AIContextProviderFactory?.Invoke(new()); + Assert.NotNull(actualProvider); + + var actualContext = await actualProvider.InvokingAsync(new([]), default); + Assert.Contains("spanish language", actualContext.Instructions); + Assert.Contains("foo", actualContext.Instructions); } [Fact] diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index 0244853..0e964fa 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -6,6 +6,7 @@ Preview true Devlooped + 10.0.0-rc.* @@ -15,16 +16,16 @@ - - - - + + + + - - + + - - + +