Skip to content

Commit 3228844

Browse files
committed
Add service-driven AIContextProviderFactory for agents
In order to extend the configured agents, in addition to providing the `configureOptions` delegate when adding agents, the user can now also rely on auto-wiring of services exported as AIContextProviderFactory. The export can be keyed to a specific agent (takes priority) or without a key (fallback). This allows granular wiring without resorting to more code.
1 parent de1ebae commit 3228844

File tree

7 files changed

+158
-3
lines changed

7 files changed

+158
-3
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using Microsoft.Agents.AI;
2+
using static Microsoft.Agents.AI.ChatClientAgentOptions;
3+
4+
namespace Devlooped.Agents.AI;
5+
6+
/// <summary>
7+
/// An implementation of an <see cref="AIContextProvider"/> factory as a class that can provide
8+
/// the functionality to <see cref="ChatClientAgentOptions.AIContextProviderFactory"/> and integrates
9+
/// more easily into a service collection.
10+
/// </summary>
11+
/// <remarks>
12+
/// The <see cref="AIContextProvider"/> is a key extensibility point in Microsoft.Agents.AI, allowing
13+
/// augmentation of instructions, messages and tools before agent execution is performed.
14+
/// </remarks>
15+
public abstract class AIContextProviderFactory
16+
{
17+
/// <summary>
18+
/// Provides the implementation of <see cref="ChatClientAgentOptions.AIContextProviderFactory"/>,
19+
/// which is invoked whenever agent threads are created or rehydrated.
20+
/// </summary>
21+
/// <param name="context">The context to potentially hydrate state from.</param>
22+
/// <returns>The context provider that will enhance interactions with an agent.</returns>
23+
public abstract AIContextProvider CreateProvider(AIContextProviderFactoryContext context);
24+
}

src/Agents/AddAIAgentsExtensions.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Devlooped.Extensions.AI;
1+
using System.ComponentModel;
2+
using Devlooped.Extensions.AI;
23
using Microsoft.Agents.AI;
34
using Microsoft.Agents.AI.Hosting;
45
using Microsoft.Extensions.Configuration;
@@ -7,8 +8,20 @@
78

89
namespace Devlooped.Agents.AI;
910

11+
/// <summary>
12+
/// Adds configuration-driven agents to an application host.
13+
/// </summary>
14+
[EditorBrowsable(EditorBrowsableState.Never)]
1015
public static class AddAIAgentsExtensions
1116
{
17+
/// <summary>
18+
/// Adds AI agents to the host application builder based on configuration.
19+
/// </summary>
20+
/// <param name="builder">The host application builder.</param>
21+
/// <param name="configurePipeline">Optional action to configure the pipeline for each agent.</param>
22+
/// <param name="configureOptions">Optional action to configure options for each agent.</param>
23+
/// <param name="prefix">The configuration prefix for agents, defaults to "ai:agents".</param>
24+
/// <returns>The host application builder with AI agents added.</returns>
1225
public static IHostApplicationBuilder AddAIAgents(this IHostApplicationBuilder builder, Action<string, AIAgentBuilder>? configurePipeline = default, Action<string, ChatClientAgentOptions>? configureOptions = default, string prefix = "ai:agents")
1326
{
1427
builder.AddChatClients();

src/Agents/ConfigurableAIAgent.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77

88
namespace Devlooped.Agents.AI;
99

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

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

46+
/// <inheritdoc/>
4147
public override object? GetService(Type serviceType, object? serviceKey = null) => serviceType switch
4248
{
4349
Type t when t == typeof(ChatClientAgentOptions) => options,
4450
Type t when t == typeof(IChatClient) => chat,
4551
_ => agent.GetService(serviceType, serviceKey)
4652
};
4753

54+
/// <inheritdoc/>
4855
public override string Id => agent.Id;
56+
/// <inheritdoc/>
4957
public override string? Description => agent.Description;
58+
/// <inheritdoc/>
5059
public override string DisplayName => agent.DisplayName;
51-
public override string? Name => agent.Name;
60+
/// <inheritdoc/>
61+
public override string? Name => this.name;
62+
/// <inheritdoc/>
5263
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
5364
=> agent.DeserializeThread(serializedThread, jsonSerializerOptions);
65+
/// <inheritdoc/>
5466
public override AgentThread GetNewThread() => agent.GetNewThread();
67+
/// <inheritdoc/>
5568
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
5669
=> agent.RunAsync(messages, thread, options, cancellationToken);
70+
/// <inheritdoc/>
5771
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
5872
=> agent.RunStreamingAsync(messages, thread, options, cancellationToken);
5973

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

7690
configure?.Invoke(name, options);
7791

92+
if (options.AIContextProviderFactory is null)
93+
{
94+
var contextFactory = services.GetKeyedService<AIContextProviderFactory>(name) ??
95+
services.GetService<AIContextProviderFactory>();
96+
97+
if (contextFactory is not null)
98+
options.AIContextProviderFactory = contextFactory.CreateProvider;
99+
}
100+
78101
LogConfigured(name);
79102

80103
return (new ChatClientAgent(client, options, services.GetRequiredService<ILoggerFactory>(), services), options, client);

src/Extensions/AddChatClientsExtensions.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Devlooped.Extensions.AI.OpenAI;
1+
using System.ComponentModel;
2+
using Devlooped.Extensions.AI.OpenAI;
23
using Microsoft.Extensions.AI;
34
using Microsoft.Extensions.Configuration;
45
using Microsoft.Extensions.DependencyInjection;
@@ -9,14 +10,35 @@
910

1011
namespace Devlooped.Extensions.AI;
1112

13+
/// <summary>
14+
/// Adds configuration-driven chat clients to an application host or service collection.
15+
/// </summary>
16+
[EditorBrowsable(EditorBrowsableState.Never)]
1217
public static class AddChatClientsExtensions
1318
{
19+
/// <summary>
20+
/// Adds configuration-driven chat clients to the host application builder.
21+
/// </summary>
22+
/// <param name="builder">The host application builder.</param>
23+
/// <param name="configurePipeline">Optional action to configure the pipeline for each client.</param>
24+
/// <param name="configureClient">Optional action to configure each client.</param>
25+
/// <param name="prefix">The configuration prefix for clients. Defaults to "ai:clients".</param>
26+
/// <returns>The host application builder.</returns>
1427
public static IHostApplicationBuilder AddChatClients(this IHostApplicationBuilder builder, Action<string, ChatClientBuilder>? configurePipeline = default, Action<string, IChatClient>? configureClient = default, string prefix = "ai:clients")
1528
{
1629
AddChatClients(builder.Services, builder.Configuration, configurePipeline, configureClient, prefix);
1730
return builder;
1831
}
1932

33+
/// <summary>
34+
/// Adds configuration-driven chat clients to the service collection.
35+
/// </summary>
36+
/// <param name="services">The service collection.</param>
37+
/// <param name="configuration">The configuration.</param>
38+
/// <param name="configurePipeline">Optional action to configure the pipeline for each client.</param>
39+
/// <param name="configureClient">Optional action to configure each client.</param>
40+
/// <param name="prefix">The configuration prefix for clients. Defaults to "ai:clients".</param>
41+
/// <returns>The service collection.</returns>
2042
public static IServiceCollection AddChatClients(this IServiceCollection services, IConfiguration configuration, Action<string, ChatClientBuilder>? configurePipeline = default, Action<string, IChatClient>? configureClient = default, string prefix = "ai:clients")
2143
{
2244
foreach (var entry in configuration.AsEnumerable().Where(x =>

src/Extensions/ConfigurableChatClient.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010

1111
namespace Devlooped.Extensions.AI;
1212

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

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

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

4054
/// <inheritdoc/>

src/Tests/ConfigurableAgentTests.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Microsoft.Extensions.Configuration;
55
using Microsoft.Extensions.DependencyInjection;
66
using Microsoft.Extensions.Hosting;
7+
using Moq;
78

89
namespace Devlooped.Agents.AI;
910

@@ -76,5 +77,62 @@ public void CanReloadConfiguration()
7677
Assert.Equal("You are a very helpful chat agent.", agent.GetService<ChatClientAgentOptions>()?.Instructions);
7778
Assert.Equal("xai", agent.GetService<AIAgentMetadata>()?.ProviderName);
7879
}
80+
81+
[Fact]
82+
public void AssignsContextProviderFromKeyedService()
83+
{
84+
var builder = new HostApplicationBuilder();
85+
var context = Mock.Of<AIContextProvider>();
86+
87+
builder.Services.AddKeyedSingleton<AIContextProviderFactory>("bot",
88+
Mock.Of<AIContextProviderFactory>(x
89+
=> x.CreateProvider(It.IsAny<ChatClientAgentOptions.AIContextProviderFactoryContext>()) == context));
90+
91+
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
92+
{
93+
["ai:clients:chat:modelid"] = "gpt-4.1-nano",
94+
["ai:clients:chat:apikey"] = "sk-asdfasdf",
95+
["ai:agents:bot:client"] = "chat",
96+
["ai:agents:bot:options:temperature"] = "0.5",
97+
});
98+
99+
builder.AddAIAgents();
100+
101+
var app = builder.Build();
102+
var agent = app.Services.GetRequiredKeyedService<AIAgent>("bot");
103+
var options = agent.GetService<ChatClientAgentOptions>();
104+
105+
Assert.NotNull(options?.AIContextProviderFactory);
106+
Assert.Same(context, options?.AIContextProviderFactory?.Invoke(new ChatClientAgentOptions.AIContextProviderFactoryContext()));
107+
}
108+
109+
[Fact]
110+
public void AssignsContextProviderFromService()
111+
{
112+
var builder = new HostApplicationBuilder();
113+
var context = Mock.Of<AIContextProvider>();
114+
115+
builder.Services.AddSingleton<AIContextProviderFactory>(
116+
Mock.Of<AIContextProviderFactory>(x
117+
=> x.CreateProvider(It.IsAny<ChatClientAgentOptions.AIContextProviderFactoryContext>()) == context));
118+
119+
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
120+
{
121+
["ai:clients:chat:modelid"] = "gpt-4.1-nano",
122+
["ai:clients:chat:apikey"] = "sk-asdfasdf",
123+
["ai:agents:bot:client"] = "chat",
124+
["ai:agents:bot:options:temperature"] = "0.5",
125+
});
126+
127+
builder.AddAIAgents();
128+
129+
var app = builder.Build();
130+
var agent = app.Services.GetRequiredKeyedService<AIAgent>("bot");
131+
var options = agent.GetService<ChatClientAgentOptions>();
132+
133+
Assert.NotNull(options?.AIContextProviderFactory);
134+
Assert.Same(context, options?.AIContextProviderFactory?.Invoke(new ChatClientAgentOptions.AIContextProviderFactoryContext()));
135+
}
136+
79137
}
80138

src/Tests/Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
<ItemGroup>
1111
<PackageReference Include="coverlet.collector" Version="6.0.4" />
12+
<PackageReference Include="Moq" Version="4.20.72" />
1213
<PackageReference Include="xunit" Version="2.9.3" />
1314
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
1415
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />

0 commit comments

Comments
 (0)