Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/Agents/AIContextProviderFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Microsoft.Agents.AI;
using static Microsoft.Agents.AI.ChatClientAgentOptions;

namespace Devlooped.Agents.AI;

/// <summary>
/// An implementation of an <see cref="AIContextProvider"/> factory as a class that can provide
/// the functionality to <see cref="ChatClientAgentOptions.AIContextProviderFactory"/> and integrates
/// more easily into a service collection.
/// </summary>
/// <remarks>
/// The <see cref="AIContextProvider"/> is a key extensibility point in Microsoft.Agents.AI, allowing
/// augmentation of instructions, messages and tools before agent execution is performed.
/// </remarks>
public abstract class AIContextProviderFactory
{
/// <summary>
/// Provides the implementation of <see cref="ChatClientAgentOptions.AIContextProviderFactory"/>,
/// which is invoked whenever agent threads are created or rehydrated.
/// </summary>
/// <param name="context">The context to potentially hydrate state from.</param>
/// <returns>The context provider that will enhance interactions with an agent.</returns>
public abstract AIContextProvider CreateProvider(AIContextProviderFactoryContext context);
}
15 changes: 14 additions & 1 deletion src/Agents/AddAIAgentsExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -7,8 +8,20 @@

namespace Devlooped.Agents.AI;

/// <summary>
/// Adds configuration-driven agents to an application host.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class AddAIAgentsExtensions
{
/// <summary>
/// Adds AI agents to the host application builder based on configuration.
/// </summary>
/// <param name="builder">The host application builder.</param>
/// <param name="configurePipeline">Optional action to configure the pipeline for each agent.</param>
/// <param name="configureOptions">Optional action to configure options for each agent.</param>
/// <param name="prefix">The configuration prefix for agents, defaults to "ai:agents".</param>
/// <returns>The host application builder with AI agents added.</returns>
public static IHostApplicationBuilder AddAIAgents(this IHostApplicationBuilder builder, Action<string, AIAgentBuilder>? configurePipeline = default, Action<string, ChatClientAgentOptions>? configureOptions = default, string prefix = "ai:agents")
{
builder.AddChatClients();
Expand Down
25 changes: 24 additions & 1 deletion src/Agents/ConfigurableAIAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

namespace Devlooped.Agents.AI;

/// <summary>
/// A configuration-driven <see cref="AIAgent"/> which monitors configuration changes and
/// re-applies them to the inner agent automatically.
/// </summary>
public sealed partial class ConfigurableAIAgent : AIAgent, IDisposable
{
readonly IServiceProvider services;
Expand Down Expand Up @@ -36,24 +40,34 @@ public ConfigurableAIAgent(IServiceProvider services, string section, string nam
reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null);
}

/// <summary>Disposes the client and stops monitoring configuration changes.</summary>
public void Dispose() => reloadToken?.Dispose();

/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null) => serviceType switch
{
Type t when t == typeof(ChatClientAgentOptions) => options,
Type t when t == typeof(IChatClient) => chat,
_ => agent.GetService(serviceType, serviceKey)
};

/// <inheritdoc/>
public override string Id => agent.Id;
/// <inheritdoc/>
public override string? Description => agent.Description;
/// <inheritdoc/>
public override string DisplayName => agent.DisplayName;
public override string? Name => agent.Name;
/// <inheritdoc/>
public override string? Name => this.name;
/// <inheritdoc/>
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> agent.DeserializeThread(serializedThread, jsonSerializerOptions);
/// <inheritdoc/>
public override AgentThread GetNewThread() => agent.GetNewThread();
/// <inheritdoc/>
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> agent.RunAsync(messages, thread, options, cancellationToken);
/// <inheritdoc/>
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> agent.RunStreamingAsync(messages, thread, options, cancellationToken);

Expand All @@ -75,6 +89,15 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum

configure?.Invoke(name, options);

if (options.AIContextProviderFactory is null)
{
var contextFactory = services.GetKeyedService<AIContextProviderFactory>(name) ??
services.GetService<AIContextProviderFactory>();

if (contextFactory is not null)
options.AIContextProviderFactory = contextFactory.CreateProvider;
}

LogConfigured(name);

return (new ChatClientAgent(client, options, services.GetRequiredService<ILoggerFactory>(), services), options, client);
Expand Down
24 changes: 23 additions & 1 deletion src/Extensions/AddChatClientsExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,14 +10,35 @@

namespace Devlooped.Extensions.AI;

/// <summary>
/// Adds configuration-driven chat clients to an application host or service collection.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class AddChatClientsExtensions
{
/// <summary>
/// Adds configuration-driven chat clients to the host application builder.
/// </summary>
/// <param name="builder">The host application builder.</param>
/// <param name="configurePipeline">Optional action to configure the pipeline for each client.</param>
/// <param name="configureClient">Optional action to configure each client.</param>
/// <param name="prefix">The configuration prefix for clients. Defaults to "ai:clients".</param>
/// <returns>The host application builder.</returns>
public static IHostApplicationBuilder AddChatClients(this IHostApplicationBuilder builder, Action<string, ChatClientBuilder>? configurePipeline = default, Action<string, IChatClient>? configureClient = default, string prefix = "ai:clients")
{
AddChatClients(builder.Services, builder.Configuration, configurePipeline, configureClient, prefix);
return builder;
}

/// <summary>
/// Adds configuration-driven chat clients to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="configurePipeline">Optional action to configure the pipeline for each client.</param>
/// <param name="configureClient">Optional action to configure each client.</param>
/// <param name="prefix">The configuration prefix for clients. Defaults to "ai:clients".</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddChatClients(this IServiceCollection services, IConfiguration configuration, Action<string, ChatClientBuilder>? configurePipeline = default, Action<string, IChatClient>? configureClient = default, string prefix = "ai:clients")
{
foreach (var entry in configuration.AsEnumerable().Where(x =>
Expand Down
14 changes: 14 additions & 0 deletions src/Extensions/ConfigurableChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

namespace Devlooped.Extensions.AI;

/// <summary>
/// A configuration-driven <see cref="IChatClient"/> which monitors configuration changes and
/// re-applies them to the inner client automatically.
/// </summary>
public sealed partial class ConfigurableChatClient : IDisposable, IChatClient
{
readonly IConfiguration configuration;
Expand All @@ -20,6 +24,15 @@ public sealed partial class ConfigurableChatClient : IDisposable, IChatClient
IDisposable reloadToken;
IChatClient innerClient;


/// <summary>
/// Initializes a new instance of the <see cref="ConfigurableChatClient"/> class.
/// </summary>
/// <param name="configuration">The configuration to read settings from.</param>
/// <param name="logger">The logger to use for logging.</param>
/// <param name="section">The configuration section to use.</param>
/// <param name="id">The unique identifier for the client.</param>
/// <param name="configure">An optional action to configure the client after creation.</param>
public ConfigurableChatClient(IConfiguration configuration, ILogger logger, string section, string id, Action<string, IChatClient>? configure)
{
if (section.Contains('.'))
Expand All @@ -35,6 +48,7 @@ public ConfigurableChatClient(IConfiguration configuration, ILogger logger, stri
reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null);
}

/// <summary>Disposes the client and stops monitoring configuration changes.</summary>
public void Dispose() => reloadToken?.Dispose();

/// <inheritdoc/>
Expand Down
58 changes: 58 additions & 0 deletions src/Tests/ConfigurableAgentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Moq;

namespace Devlooped.Agents.AI;

Expand Down Expand Up @@ -76,5 +77,62 @@ public void CanReloadConfiguration()
Assert.Equal("You are a very helpful chat agent.", agent.GetService<ChatClientAgentOptions>()?.Instructions);
Assert.Equal("xai", agent.GetService<AIAgentMetadata>()?.ProviderName);
}

[Fact]
public void AssignsContextProviderFromKeyedService()
{
var builder = new HostApplicationBuilder();
var context = Mock.Of<AIContextProvider>();

builder.Services.AddKeyedSingleton<AIContextProviderFactory>("bot",
Mock.Of<AIContextProviderFactory>(x
=> x.CreateProvider(It.IsAny<ChatClientAgentOptions.AIContextProviderFactoryContext>()) == context));

builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<AIAgent>("bot");
var options = agent.GetService<ChatClientAgentOptions>();

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<AIContextProvider>();

builder.Services.AddSingleton<AIContextProviderFactory>(
Mock.Of<AIContextProviderFactory>(x
=> x.CreateProvider(It.IsAny<ChatClientAgentOptions.AIContextProviderFactoryContext>()) == context));

builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<AIAgent>("bot");
var options = agent.GetService<ChatClientAgentOptions>();

Assert.NotNull(options?.AIContextProviderFactory);
Assert.Same(context, options?.AIContextProviderFactory?.Invoke(new ChatClientAgentOptions.AIContextProviderFactoryContext()));
}

}

1 change: 1 addition & 0 deletions src/Tests/Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
Expand Down