From f4769e4135032ab282de4f8ca6584060226a2629 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Mon, 27 Oct 2025 16:14:33 -0300 Subject: [PATCH] Add support for configurable and composable AI contexts In order to make configured agents more dynamic and extensible, we introduce the `use` setting which is an array of named AI contexts to include as part of the agent execution. These cannot be used at the same time with AIContextProvider (for now?) to simplify usage. You either pick exporting your own context provider (or factory), or you rely on more granular and reusable AIContext exported services. --- src/Agents/CompositeAIContextProvider.cs | 52 +++++ src/Agents/ConfigurableAIAgent.cs | 39 ++++ src/Tests/ConfigurableAgentTests.cs | 273 ++++++++++++++++++++--- src/Tests/Extensions/Configuration.cs | 5 + src/Tests/Tests.csproj | 5 + 5 files changed, 342 insertions(+), 32 deletions(-) create mode 100644 src/Agents/CompositeAIContextProvider.cs 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 @@ + +