diff --git a/src/Agents/AIContextProviderFactory.cs b/src/Agents/AIContextProviderFactory.cs new file mode 100644 index 0000000..c70de44 --- /dev/null +++ b/src/Agents/AIContextProviderFactory.cs @@ -0,0 +1,24 @@ +using Microsoft.Agents.AI; +using static Microsoft.Agents.AI.ChatClientAgentOptions; + +namespace Devlooped.Agents.AI; + +/// +/// An implementation of an factory as a class that can provide +/// the functionality to and integrates +/// more easily into a service collection. +/// +/// +/// The is a key extensibility point in Microsoft.Agents.AI, allowing +/// augmentation of instructions, messages and tools before agent execution is performed. +/// +public abstract class AIContextProviderFactory +{ + /// + /// Provides the implementation of , + /// which is invoked whenever agent threads are created or rehydrated. + /// + /// The context to potentially hydrate state from. + /// The context provider that will enhance interactions with an agent. + public abstract AIContextProvider CreateProvider(AIContextProviderFactoryContext context); +} diff --git a/src/Agents/AddAIAgentsExtensions.cs b/src/Agents/AddAIAgentsExtensions.cs index 89c2f95..74d2e48 100644 --- a/src/Agents/AddAIAgentsExtensions.cs +++ b/src/Agents/AddAIAgentsExtensions.cs @@ -1,4 +1,5 @@ -using Devlooped.Extensions.AI; +using System.ComponentModel; +using Devlooped.Extensions.AI; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; using Microsoft.Extensions.Configuration; @@ -7,8 +8,20 @@ namespace Devlooped.Agents.AI; +/// +/// Adds configuration-driven agents to an application host. +/// +[EditorBrowsable(EditorBrowsableState.Never)] public static class AddAIAgentsExtensions { + /// + /// Adds AI agents to the host application builder based on configuration. + /// + /// The host application builder. + /// Optional action to configure the pipeline for each agent. + /// 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 IHostApplicationBuilder AddAIAgents(this IHostApplicationBuilder builder, Action? configurePipeline = default, Action? configureOptions = default, string prefix = "ai:agents") { builder.AddChatClients(); diff --git a/src/Agents/ConfigurableAIAgent.cs b/src/Agents/ConfigurableAIAgent.cs index 6b69c35..eaa9f34 100644 --- a/src/Agents/ConfigurableAIAgent.cs +++ b/src/Agents/ConfigurableAIAgent.cs @@ -7,6 +7,10 @@ namespace Devlooped.Agents.AI; +/// +/// A configuration-driven which monitors configuration changes and +/// re-applies them to the inner agent automatically. +/// public sealed partial class ConfigurableAIAgent : AIAgent, IDisposable { readonly IServiceProvider services; @@ -36,8 +40,10 @@ public ConfigurableAIAgent(IServiceProvider services, string section, string nam reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null); } + /// Disposes the client and stops monitoring configuration changes. public void Dispose() => reloadToken?.Dispose(); + /// public override object? GetService(Type serviceType, object? serviceKey = null) => serviceType switch { Type t when t == typeof(ChatClientAgentOptions) => options, @@ -45,15 +51,23 @@ public ConfigurableAIAgent(IServiceProvider services, string section, string nam _ => agent.GetService(serviceType, serviceKey) }; + /// public override string Id => agent.Id; + /// public override string? Description => agent.Description; + /// public override string DisplayName => agent.DisplayName; - public override string? Name => agent.Name; + /// + public override string? Name => this.name; + /// public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) => agent.DeserializeThread(serializedThread, jsonSerializerOptions); + /// public override AgentThread GetNewThread() => agent.GetNewThread(); + /// public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => agent.RunAsync(messages, thread, options, cancellationToken); + /// public override IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => agent.RunStreamingAsync(messages, thread, options, cancellationToken); @@ -75,6 +89,15 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum configure?.Invoke(name, options); + if (options.AIContextProviderFactory is null) + { + var contextFactory = services.GetKeyedService(name) ?? + services.GetService(); + + if (contextFactory is not null) + options.AIContextProviderFactory = contextFactory.CreateProvider; + } + LogConfigured(name); return (new ChatClientAgent(client, options, services.GetRequiredService(), services), options, client); diff --git a/src/Extensions/AddChatClientsExtensions.cs b/src/Extensions/AddChatClientsExtensions.cs index 58ffb77..dd06c4b 100644 --- a/src/Extensions/AddChatClientsExtensions.cs +++ b/src/Extensions/AddChatClientsExtensions.cs @@ -1,4 +1,5 @@ -using Devlooped.Extensions.AI.OpenAI; +using System.ComponentModel; +using Devlooped.Extensions.AI.OpenAI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -9,14 +10,35 @@ namespace Devlooped.Extensions.AI; +/// +/// Adds configuration-driven chat clients to an application host or service collection. +/// +[EditorBrowsable(EditorBrowsableState.Never)] public static class AddChatClientsExtensions { + /// + /// Adds configuration-driven chat clients to the host application builder. + /// + /// The host application builder. + /// Optional action to configure the pipeline for each client. + /// Optional action to configure each client. + /// The configuration prefix for clients. Defaults to "ai:clients". + /// The host application builder. public static IHostApplicationBuilder AddChatClients(this IHostApplicationBuilder builder, Action? configurePipeline = default, Action? configureClient = default, string prefix = "ai:clients") { AddChatClients(builder.Services, builder.Configuration, configurePipeline, configureClient, prefix); return builder; } + /// + /// Adds configuration-driven chat clients to the service collection. + /// + /// The service collection. + /// The configuration. + /// Optional action to configure the pipeline for each client. + /// Optional action to configure each client. + /// The configuration prefix for clients. Defaults to "ai:clients". + /// The service collection. public static IServiceCollection AddChatClients(this IServiceCollection services, IConfiguration configuration, Action? configurePipeline = default, Action? configureClient = default, string prefix = "ai:clients") { foreach (var entry in configuration.AsEnumerable().Where(x => diff --git a/src/Extensions/ConfigurableChatClient.cs b/src/Extensions/ConfigurableChatClient.cs index 32b7994..c1d59d3 100644 --- a/src/Extensions/ConfigurableChatClient.cs +++ b/src/Extensions/ConfigurableChatClient.cs @@ -10,6 +10,10 @@ namespace Devlooped.Extensions.AI; +/// +/// A configuration-driven which monitors configuration changes and +/// re-applies them to the inner client automatically. +/// public sealed partial class ConfigurableChatClient : IDisposable, IChatClient { readonly IConfiguration configuration; @@ -20,6 +24,15 @@ public sealed partial class ConfigurableChatClient : IDisposable, IChatClient IDisposable reloadToken; IChatClient innerClient; + + /// + /// Initializes a new instance of the class. + /// + /// The configuration to read settings from. + /// The logger to use for logging. + /// The configuration section to use. + /// The unique identifier for the client. + /// An optional action to configure the client after creation. public ConfigurableChatClient(IConfiguration configuration, ILogger logger, string section, string id, Action? configure) { if (section.Contains('.')) @@ -35,6 +48,7 @@ public ConfigurableChatClient(IConfiguration configuration, ILogger logger, stri reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null); } + /// Disposes the client and stops monitoring configuration changes. public void Dispose() => reloadToken?.Dispose(); /// diff --git a/src/Tests/ConfigurableAgentTests.cs b/src/Tests/ConfigurableAgentTests.cs index 9bebe1e..66784c8 100644 --- a/src/Tests/ConfigurableAgentTests.cs +++ b/src/Tests/ConfigurableAgentTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Moq; namespace Devlooped.Agents.AI; @@ -76,5 +77,62 @@ public void CanReloadConfiguration() Assert.Equal("You are a very helpful chat agent.", agent.GetService()?.Instructions); 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() + { + var builder = new HostApplicationBuilder(); + var context = Mock.Of(); + + builder.Services.AddSingleton( + 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())); + } + } diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index 225c767..0ba67c8 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -9,6 +9,7 @@ +