diff --git a/readme.md b/readme.md index 1ab4026..ab2e94e 100644 --- a/readme.md +++ b/readme.md @@ -114,10 +114,8 @@ modelid = "grok-4-fast-non-reasoning" [ai.agents.orders] description = "Manage orders using catalogs for food or any other item." instructions = """ - You are an AI agent responsible for processing orders for food or other items. Your primary goals are to identify user intent, extract or request provider information, manage order data using tools and friendly responses to guide users through the ordering process. - """ # ai.clients.openai, can omit the ai.clients prefix @@ -135,6 +133,86 @@ This can be used by leveraging [Tomlyn.Extensions.Configuration](https://www.nug > avoiding unnecessary tokens being used for indentation while allowing flexible > formatting in the config file. +### Extensible AI Contexts + +The Microsoft [agent framework](https://learn.microsoft.com/en-us/agent-framework/overview/agent-framework-overview) allows extending +agents with dynamic context via [AIContextProvider](https://learn.microsoft.com/en-us/dotnet/api/microsoft.agents.ai.aicontextprovider) +and `AIContext`. This package supports dynamic extension of a configured agent in the following ways (in order of priority): + +1. A keyed service `AIContextProviderFactory` with the same name as the agent will be set up just as if you had + set it manually as the [ChatClientAgentOptions.AIContextProviderFactory](https://learn.microsoft.com/en-us/dotnet/api/microsoft.agents.ai.chatclientagentoptions.aicontextproviderfactory) + in code. +2. A keyed service `AIContextProvider` with the same name as the agent. +3. A keyed service `AIContext` with the same name as the agent. +4. Configured `AIContext` sections pulled in via `use` setting for an agent. + +The first three alternatives enable auto-wiring of context providers or contexts registered in the service collection and +are pretty self-explanatory. The last alternative allows even more declarative scenarios involving reusable and cross-cutting +context definitions. + +For example, let's say you want to provide consistent tone for all your agents. It would be tedious, repetitive and harder +to maintain if you had to set that in each agent's instructions. Instead, you can define a reusable context named `tone` such as: + +```toml +[ai.context.tone] +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". + """ +``` + +Then, you can reference that context in any agent using the `use` setting: +```toml +[ai.agents.support] +description = "An AI agent that helps with customer support." +instructions = "..." +client = "grok" +use = ["tone"] + +[ai.agents.sales] +description = "An AI agent that helps with sales inquiries." +instructions = "..." +client = "openai" +use = ["tone"] +``` + +Configured contexts can provide all three components of an `AIContext`: instructions, messages and tools, such as: + +```toml +[ai.context.timezone] +instructions = "Always assume the user's timezone is America/Argentina/Buenos_Aires unless specified otherwise." +messages = [ + { system = "You are aware of the current date and time in America/Argentina/Buenos_Aires." } +] +tools = ["get_date"] +``` + +If multiple contexts are specified in `use`, they are applied in order, concatenating their instructions, messages and tools. + +The `tools` section allows specifying tool names registered in the DI container, such as: + +```csharp +services.AddKeyedSingleton("get_date", AIFunctionFactory.Create(() => DateTimeOffset.Now, "get_date")); +``` + +This tool will be automatically wired into any agent that uses the `timezone` context above. + +As a shortcut when you want to just pull in a tool from DI into an agent's context without having to define an entire +section just for that, you can specify the tool name directly in the `use` array: + +```toml +[ai.agents.support] +description = "An AI agent that helps with customer support." +instructions = "..." +client = "grok" +use = ["tone", "get_date"] +``` + +This enables a flexible and convenient mix of static and dynamic context for agents, all driven +from configuration. + diff --git a/sample/Server/ConsoleExtensions.cs b/sample/Server/ConsoleExtensions.cs index c2890da..018256c 100644 --- a/sample/Server/ConsoleExtensions.cs +++ b/sample/Server/ConsoleExtensions.cs @@ -3,45 +3,65 @@ using Microsoft.Agents.AI.Hosting; using Microsoft.Extensions.AI; using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; using Spectre.Console; using Spectre.Console.Json; public static class ConsoleExtensions { - public static async ValueTask RenderAgentsAsync(this IServiceProvider services, IServiceCollection collection) + extension(IServiceProvider services) { - var catalog = services.GetRequiredService(); - var settings = new JsonSerializerSettings + public async ValueTask RenderAgentsAsync(IServiceCollection collection) { - NullValueHandling = NullValueHandling.Include, - DefaultValueHandling = DefaultValueHandling.Ignore - }; + var catalog = services.GetRequiredService(); + var settings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Include, + DefaultValueHandling = DefaultValueHandling.Ignore, + ContractResolver = new IgnoreDelegatePropertiesResolver(), + }; - // List configured clients - foreach (var description in collection.AsEnumerable().Where(x => x.ServiceType == typeof(IChatClient) && x.IsKeyedService && x.ServiceKey is string)) - { - var client = services.GetKeyedService(description.ServiceKey); - if (client is null) - continue; + // List configured clients + foreach (var description in collection.AsEnumerable().Where(x => x.ServiceType == typeof(IChatClient) && x.IsKeyedService && x.ServiceKey is string)) + { + var client = services.GetKeyedService(description.ServiceKey); + if (client is null) + continue; + + var metadata = client.GetService(); + var chatopt = (client as ConfigurableChatClient)?.Options; - var metadata = client.GetService(); - var chatopt = (client as ConfigurableChatClient)?.Options; + AnsiConsole.Write(new Panel(new JsonText(JsonConvert.SerializeObject(new { Metadata = metadata, Options = chatopt }, settings))) + { + Header = new PanelHeader($"| 💬 {metadata?.Id} from {metadata?.ConfigurationSection} |"), + }); + } - AnsiConsole.Write(new Panel(new JsonText(JsonConvert.SerializeObject(new { Metadata = metadata, Options = chatopt }, settings))) + // List configured agents + await foreach (var agent in catalog.GetAgentsAsync()) { - Header = new PanelHeader($"| 💬 {metadata?.Id} from {metadata?.ConfigurationSection} |"), - }); + var metadata = agent.GetService(); + + AnsiConsole.Write(new Panel(new JsonText(JsonConvert.SerializeObject(new { Agent = agent, Metadata = metadata }, settings))) + { + Header = new PanelHeader($"| 🤖 {agent.DisplayName} from {metadata?.ConfigurationSection} |"), + }); + } } + } - // List configured agents - await foreach (var agent in catalog.GetAgentsAsync()) + class IgnoreDelegatePropertiesResolver : DefaultContractResolver + { + protected override JsonProperty CreateProperty(System.Reflection.MemberInfo member, MemberSerialization memberSerialization) { - var metadata = agent.GetService(); + var property = base.CreateProperty(member, memberSerialization); - AnsiConsole.Write(new Panel(new JsonText(JsonConvert.SerializeObject(new { Agent = agent, Metadata = metadata }, settings))) + if (property.PropertyType != null && typeof(Delegate).IsAssignableFrom(property.PropertyType)) { - Header = new PanelHeader($"| 🤖 {agent.DisplayName} from {metadata?.ConfigurationSection} |"), - }); + property.ShouldSerialize = _ => false; + } + + return property; } } } diff --git a/sample/Server/Program.cs b/sample/Server/Program.cs index dc07688..e14f785 100644 --- a/sample/Server/Program.cs +++ b/sample/Server/Program.cs @@ -1,12 +1,10 @@ using System.Runtime.InteropServices; using System.Text; using Devlooped.Extensions.AI; -using DotNetEnv.Configuration; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.OpenAI; using Microsoft.Extensions.AI; using Spectre.Console; -using Tomlyn.Extensions.Configuration; var builder = WebApplication.CreateBuilder(args); @@ -20,6 +18,9 @@ builder.AddServiceDefaults(); builder.ConfigureReload(); +// 👇 showcases using dynamic AI context from configuration +builder.Services.AddKeyedSingleton("get_date", AIFunctionFactory.Create(() => DateTimeOffset.UtcNow, "get_date")); + // 👇 implicitly calls AddChatClients builder.AddAIAgents(); diff --git a/sample/Server/ai.toml b/sample/Server/ai.toml index 15b0804..d0bd412 100644 --- a/sample/Server/ai.toml +++ b/sample/Server/ai.toml @@ -1,4 +1,4 @@ -[ai.clients.openai] +[ai.clients.openai] modelid = "gpt-4.1" [ai.clients.grok] @@ -7,14 +7,23 @@ modelid = "grok-4-fast-non-reasoning" [ai.agents.orders] description = "Manage orders using catalogs for food or any other item." -instructions = """ +instructions = """\ You are an AI agent responsible for processing orders for food or other items. Your primary goals are to identify user intent, extract or request provider information, manage order data using tools and friendly responses to guide users through the ordering process. """ # ai.clients.openai, can omit the ai.clients prefix client = "openai" +use = ["tone", "get_date"] [ai.agents.reminder.options] modelid = "gpt-4o-mini" # additional properties could be added here + +[ai.context.tone] +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". + """ \ No newline at end of file diff --git a/sample/ServiceDefaults.cs b/sample/ServiceDefaults.cs index b0fa98b..e6a4f4e 100644 --- a/sample/ServiceDefaults.cs +++ b/sample/ServiceDefaults.cs @@ -72,6 +72,10 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) return builder; } + /// + /// Configures automatic configuration reload from either the build output directory (production) + /// or from the project directory (development). + /// public static TBuilder ConfigureReload(this TBuilder builder) where TBuilder : IHostApplicationBuilder { diff --git a/src/Agents/ConfigurableAIAgent.cs b/src/Agents/ConfigurableAIAgent.cs index 818a684..aec3825 100644 --- a/src/Agents/ConfigurableAIAgent.cs +++ b/src/Agents/ConfigurableAIAgent.cs @@ -1,4 +1,6 @@ -using System.Diagnostics; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Text.Json; using Devlooped.Extensions.AI; using Devlooped.Extensions.AI.Grok; @@ -132,18 +134,46 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum foreach (var use in options.Use) { var context = services.GetKeyedService(use); - if (context is null) + if (context is not 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(context); + continue; + } + var function = services.GetKeyedService(use) ?? services.GetKeyedService(use); + if (function is not null) + { contexts.Add(new AIContext { Tools = [function] }); + continue; } - else + + if (configuration.GetSection("ai:context:" + use) is { } ctxSection && + ctxSection.Get() is { } ctxConfig) { - contexts.Add(context); + var configured = new AIContext(); + if (ctxConfig.Instructions is not null) + configured.Instructions = ctxConfig.Instructions.Dedent(); + if (ctxConfig.Messages is { Count: > 1 } messages) + configured.Messages = messages; + + if (ctxConfig.Tools is not null) + { + foreach (var toolName in ctxConfig.Tools) + { + var tool = services.GetKeyedService(toolName) ?? + services.GetKeyedService(toolName) ?? + throw new InvalidOperationException($"Specified tool '{toolName}' for AI context '{ctxSection.Path}:tools' is not registered, and is required by agent section '{configSection.Path}'."); + + configured.Tools ??= []; + configured.Tools.Add(tool); + } + } + + contexts.Add(configured); + continue; } + + throw new InvalidOperationException($"Specified AI context '{use}' for agent '{name}' is not registered as either {nameof(AIContent)}, {nameof(AITool)} or configuration section 'ai:context:{use}'."); } options.AIContextProviderFactory = _ => new CompositeAIContextProvider(contexts); @@ -199,4 +229,24 @@ public class ConfigurableAIAgentMetadata(string name, string configurationSectio public string Name => name; /// Configuration section where the agent is defined. public string ConfigurationSection = configurationSection; -} \ No newline at end of file +} + +class AIContextConfiguration +{ + public string? Instructions { get; set; } + + public IList? Messages => + MessageConfigurations?.Select(config => + config.System is not null ? new ChatMessage(ChatRole.System, config.System) : + config.User is not null ? new ChatMessage(ChatRole.User, config.User) : + config.Assistant is not null ? new ChatMessage(ChatRole.Assistant, config.Assistant) : + null).Where(x => x is not null).Cast().ToList(); + + public IList? Tools { get; set; } + + [EditorBrowsable(EditorBrowsableState.Never)] + [ConfigurationKeyName("Messages")] + public MessageConfiguration[]? MessageConfigurations { get; set; } +} + +record MessageConfiguration(string? System = default, string? User = default, string? Assistant = default); diff --git a/src/Tests/ConfigurableAgentTests.cs b/src/Tests/ConfigurableAgentTests.cs index ab10616..9c48776 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 OpenAI.Assistants; using Tomlyn.Extensions.Configuration; namespace Devlooped.Agents.AI; @@ -406,12 +407,7 @@ public void UseAndContextProviderFactoryIncompatible() 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". - """ + instructions = 'Default to using spanish language, using argentinean "voseo" in your responses' """"); builder.AddAIAgents(configureOptions: (name, options) @@ -566,5 +562,113 @@ public async Task UseAIToolFromKeyedServiceAsync() Assert.Single(context.Tools); Assert.Same(tool, context.Tools[0]); } + + [Fact] + public async Task UseAIContextFromSection() + { + var builder = new HostApplicationBuilder(); + var voseo = + """ + Default to using spanish language, using argentinean "voseo" in your responses. + """; + + builder.Configuration.AddToml( + $$""" + [ai.clients.openai] + modelid = "gpt-4.1" + apikey = "sk-asdf" + + [ai.agents.chat] + description = "Chat agent." + client = "openai" + use = ["default"] + + [ai.context.default] + instructions = '{{voseo}}' + messages = [ + { system = "You are strictly professional." }, + { user = "Hey you!"}, + { assistant = "Hello there. How can I assist you today?" } + ] + tools = ["get_date"] + """); + + var 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.NotNull(context.Instructions); + Assert.Equal(voseo, context.Instructions); + Assert.Equal(3, context.Messages?.Count); + Assert.Single(context.Messages!, x => x.Role == ChatRole.System && x.Text == "You are strictly professional."); + Assert.Single(context.Messages!, x => x.Role == ChatRole.User && x.Text == "Hey you!"); + Assert.Single(context.Messages!, x => x.Role == ChatRole.Assistant && x.Text == "Hello there. How can I assist you today?"); + Assert.Same(tool, context.Tools?.First()); + } + + [Fact] + public async Task MissingToolAIContextFromSectionThrows() + { + 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 = ["default"] + + [ai.context.default] + tools = ["get_date"] + """); + + builder.AddAIAgents(); + var app = builder.Build(); + + var exception = Assert.ThrowsAny(() => app.Services.GetRequiredKeyedService("chat")); + + Assert.Contains("get_date", exception.Message); + Assert.Contains("ai:context:default:tools", exception.Message); + Assert.Contains("ai:agents:chat", exception.Message); + } + + [Fact] + public async Task UnknownUseThrows() + { + 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 = ["foo"] + """); + + builder.AddAIAgents(); + var app = builder.Build(); + + var exception = Assert.ThrowsAny(() => app.Services.GetRequiredKeyedService("chat")); + + Assert.Contains("foo", exception.Message); + } }