diff --git a/src/Agents/CompositeAIContextProvider.cs b/src/Agents/CompositeAIContextProvider.cs new file mode 100644 index 0000000..ca0d181 --- /dev/null +++ b/src/Agents/CompositeAIContextProvider.cs @@ -0,0 +1,52 @@ +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace Devlooped.Agents.AI; + +/// +/// Concatenates multiple instances into a single one. +/// +class CompositeAIContextProvider : AIContextProvider +{ + readonly AIContext context; + + public CompositeAIContextProvider(IList contexts) + { + if (contexts.Count == 1) + { + context = contexts[0]; + return; + } + + // Concatenate instructions from all contexts + context = new(); + var instructions = new List(); + var messages = new List(); + var tools = new List(); + + foreach (var ctx in contexts) + { + if (!string.IsNullOrEmpty(ctx.Instructions)) + instructions.Add(ctx.Instructions); + + if (ctx.Messages != null) + messages.AddRange(ctx.Messages); + + if (ctx.Tools != null) + tools.AddRange(ctx.Tools); + } + + // Same separator used by M.A.AI for instructions appending from AIContext + if (instructions.Count > 0) + context.Instructions = string.Join('\n', instructions); + + if (messages.Count > 0) + context.Messages = messages; + + if (tools.Count > 0) + context.Tools = tools; + } + + public override ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) + => ValueTask.FromResult(this.context); +} \ No newline at end of file diff --git a/src/Agents/ConfigurableAIAgent.cs b/src/Agents/ConfigurableAIAgent.cs index 4701ad7..818a684 100644 --- a/src/Agents/ConfigurableAIAgent.cs +++ b/src/Agents/ConfigurableAIAgent.cs @@ -113,7 +113,45 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum services.GetService(); 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."); + 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) + { + var contexts = new List(); + foreach (var use in options.Use) + { + var context = services.GetKeyedService(use); + if (context is null) + { + var function = services.GetKeyedService(use) ?? + services.GetKeyedService(use) ?? + throw new InvalidOperationException($"Specified AI context '{use}' for agent '{name}' is not registered as either an {nameof(AIContent)} or an {nameof(AITool)}."); + + contexts.Add(new AIContext { Tools = [function] }); + } + else + { + contexts.Add(context); + } + } + + options.AIContextProviderFactory = _ => new CompositeAIContextProvider(contexts); + } + } + else if (options.Use?.Count > 0) + { + throw new InvalidOperationException($"Invalid simultaneous use of {nameof(ChatClientAgentOptions)}.{nameof(ChatClientAgentOptions.AIContextProviderFactory)} and '{section}:use' in configuration."); } if (options.ChatMessageStoreFactory is null) @@ -148,6 +186,7 @@ void OnReload(object? state) internal class AgentClientOptions : ChatClientAgentOptions { public string? Client { get; set; } + public IList? Use { get; set; } } } diff --git a/src/Tests/ConfigurableAgentTests.cs b/src/Tests/ConfigurableAgentTests.cs index 66f542d..ab10616 100644 --- a/src/Tests/ConfigurableAgentTests.cs +++ b/src/Tests/ConfigurableAgentTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Moq; +using Tomlyn.Extensions.Configuration; namespace Devlooped.Agents.AI; @@ -178,34 +179,6 @@ public void CanReloadConfiguration() Assert.Equal("xai", agent.GetService()?.ProviderName); } - [Fact] - public void AssignsContextProviderFromKeyedService() - { - var builder = new HostApplicationBuilder(); - var context = Mock.Of(); - - builder.Services.AddKeyedSingleton("bot", - Mock.Of(x - => x.CreateProvider(It.IsAny()) == context)); - - builder.Configuration.AddInMemoryCollection(new Dictionary - { - ["ai:clients:chat:modelid"] = "gpt-4.1-nano", - ["ai:clients:chat:apikey"] = "sk-asdfasdf", - ["ai:agents:bot:client"] = "chat", - ["ai:agents:bot:options:temperature"] = "0.5", - }); - - builder.AddAIAgents(); - - var app = builder.Build(); - var agent = app.Services.GetRequiredKeyedService("bot"); - var options = agent.GetService(); - - Assert.NotNull(options?.AIContextProviderFactory); - Assert.Same(context, options?.AIContextProviderFactory?.Invoke(new ChatClientAgentOptions.AIContextProviderFactoryContext())); - } - [Fact] public void AssignsContextProviderFromService() { @@ -350,12 +323,248 @@ public void CanSetGrokOptions() } [Fact] - public void Task() + public void UseContextProviderFactoryFromKeyedService() + { + var builder = new HostApplicationBuilder(); + var context = Mock.Of(); + + builder.Services.AddKeyedSingleton("bot", + Mock.Of(x + => x.CreateProvider(It.IsAny()) == context)); + + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["ai:clients:chat:modelid"] = "gpt-4.1-nano", + ["ai:clients:chat:apikey"] = "sk-asdfasdf", + ["ai:agents:bot:client"] = "chat", + ["ai:agents:bot:options:temperature"] = "0.5", + }); + + builder.AddAIAgents(); + + var app = builder.Build(); + var agent = app.Services.GetRequiredKeyedService("bot"); + var options = agent.GetService(); + + Assert.NotNull(options?.AIContextProviderFactory); + Assert.Same(context, options?.AIContextProviderFactory?.Invoke(new ChatClientAgentOptions.AIContextProviderFactoryContext())); + } + + [Fact] + public async Task UseContextProviderFromKeyedServiceAsync() + { + var builder = new HostApplicationBuilder(); + var context = new AIContext(); + + var provider = new Mock(); + provider + .Setup(x => x.InvokingAsync(It.IsAny(), default(CancellationToken))) + .ReturnsAsync(context); + + builder.Services.AddKeyedSingleton("chat", provider.Object); + + builder.Configuration.AddToml( + """" + [ai.clients.openai] + modelid = "gpt-4.1" + apikey = "sk-asdf" + + [ai.agents.chat] + description = "Chat agent." + client = "openai" + """"); + + builder.AddAIAgents(); + + var app = builder.Build(); + var agent = app.Services.GetRequiredKeyedService("chat"); + var options = agent.GetService(); + + Assert.NotNull(options?.AIContextProviderFactory); + + var actualProvider = options?.AIContextProviderFactory?.Invoke(new()); + + Assert.NotNull(actualProvider); + + Assert.Same(context, await actualProvider.InvokingAsync(new([]), default)); + } + + [Fact] + public void UseAndContextProviderFactoryIncompatible() { - var agent = new AgentRunResponse() { AgentId = "agent-123" }; - var chat = agent.AsChatResponse(); + var builder = new HostApplicationBuilder(); + + builder.Configuration.AddToml( + """" + [ai.clients.openai] + modelid = "gpt-4.1" + apikey = "sk-asdf" + + [ai.agents.chat] + description = "Chat agent." + client = "openai" + use = ["voseo"] + + [ai.context.voseo] + instructions = """\ + Default to using spanish language, using argentinean "voseo" in your responses \ + (unless the user explicitly talks in a different language). \ + This means using "vos" instead of "tú" and conjugating verbs accordingly. \ + Don't use the expression "pa'" instead of "para". Don't mention the word "voseo". + """ + """"); + + builder.AddAIAgents(configureOptions: (name, options) + => options.AIContextProviderFactory = context => Mock.Of()); + + var app = builder.Build(); + + var exception = Assert.ThrowsAny(() => app.Services.GetRequiredKeyedService("chat")); + + Assert.Contains("ai:agents:chat:use", exception.Message); + } + + [Fact] + public void UseAndContextProviderIncompatible() + { + var builder = new HostApplicationBuilder(); + + builder.Configuration.AddToml( + """" + [ai.clients.openai] + modelid = "gpt-4.1" + apikey = "sk-asdf" + + [ai.agents.chat] + description = "Chat agent." + client = "openai" + use = ["voseo"] + + [ai.context.voseo] + instructions = """\ + Default to using spanish language, using argentinean "voseo" in your responses \ + (unless the user explicitly talks in a different language). \ + This means using "vos" instead of "tú" and conjugating verbs accordingly. \ + Don't use the expression "pa'" instead of "para". Don't mention the word "voseo". + """ + """"); + + builder.Services.AddKeyedSingleton("chat", Mock.Of()); + builder.AddAIAgents(); + + var app = builder.Build(); + var exception = Assert.ThrowsAny(() => app.Services.GetRequiredKeyedService("chat")); + + Assert.Contains("ai:agents:chat:use", exception.Message); + } + + [Fact] + public async Task UseAIContextFromKeyedServiceAsync() + { + var builder = new HostApplicationBuilder(); + var voseo = new AIContext { Instructions = "voseo" }; + + builder.Configuration.AddToml( + """" + [ai.clients.openai] + modelid = "gpt-4.1" + apikey = "sk-asdf" + + [ai.agents.chat] + description = "Chat agent." + client = "openai" + use = ["voseo"] + """"); + + builder.Services.AddKeyedSingleton("voseo", voseo); + + builder.AddAIAgents(); + + var app = builder.Build(); + var agent = app.Services.GetRequiredKeyedService("chat"); + 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.Same(voseo, await actualProvider.InvokingAsync(new([]), default)); + } + + [Fact] + public async Task UseAggregatedAIContextsFromKeyedServiceAsync() + { + var builder = new HostApplicationBuilder(); + var voseo = new AIContext { Instructions = "voseo" }; + var formatting = new AIContext { Instructions = "formatting" }; + + builder.Configuration.AddToml( + """" + [ai.clients.openai] + modelid = "gpt-4.1" + apikey = "sk-asdf" + + [ai.agents.chat] + description = "Chat agent." + client = "openai" + use = ["voseo", "formatting"] + """"); + + builder.Services.AddKeyedSingleton("voseo", voseo); + builder.Services.AddKeyedSingleton("formatting", formatting); + + builder.AddAIAgents(); + + var app = builder.Build(); + var agent = app.Services.GetRequiredKeyedService("chat"); + 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.StartsWith(voseo.Instructions, actualContext.Instructions); + Assert.EndsWith(formatting.Instructions, actualContext.Instructions); + } + + [Fact] + public async Task UseAIToolFromKeyedServiceAsync() + { + var builder = new HostApplicationBuilder(); + + builder.Configuration.AddToml( + """" + [ai.clients.openai] + modelid = "gpt-4.1" + apikey = "sk-asdf" + + [ai.agents.chat] + description = "Chat agent." + client = "openai" + use = ["get_date"] + """"); + + AITool tool = AIFunctionFactory.Create(() => DateTimeOffset.Now, "get_date"); + builder.Services.AddKeyedSingleton("get_date", tool); + builder.AddAIAgents(); + + var app = builder.Build(); + var agent = app.Services.GetRequiredKeyedService("chat"); + var options = agent.GetService(); + + Assert.NotNull(options?.AIContextProviderFactory); + var provider = options?.AIContextProviderFactory?.Invoke(new()); + Assert.NotNull(provider); + + var context = await provider.InvokingAsync(new([]), default); - Assert.Equal("agent-123", chat.AdditionalProperties!["AgentId"]); + Assert.NotNull(context.Tools); + Assert.Single(context.Tools); + Assert.Same(tool, context.Tools[0]); } } diff --git a/src/Tests/Extensions/Configuration.cs b/src/Tests/Extensions/Configuration.cs index a8f0d29..17a2e04 100644 --- a/src/Tests/Extensions/Configuration.cs +++ b/src/Tests/Extensions/Configuration.cs @@ -1,10 +1,15 @@ using System.Reflection; +using System.Text; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Tomlyn.Extensions.Configuration; public static class ConfigurationExtensions { + public static IConfigurationBuilder AddToml(this IConfigurationBuilder builder, string contents) + => builder.AddTomlStream(new MemoryStream(Encoding.UTF8.GetBytes(contents))); + public static IConfiguration Configuration { get; } = new ConfigurationBuilder() .AddEnvironmentVariables() .AddUserSecrets(Assembly.GetExecutingAssembly()) diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index b7e726d..0244853 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -5,6 +5,7 @@ OPENAI001;$(NoWarn) Preview true + Devlooped @@ -24,6 +25,8 @@ + + @@ -38,8 +41,10 @@ + +