Skip to content

Commit d995248

Browse files
committed
Initial support for configurable AI agents
Following the pattern from configurable chat clients. Unlike chat clients, agents use the name property as the key (enforced by the underlying Agents.AI.Hosting library). We rename the UseChatClients to AddChatClients since that's more consistent with the Add/Use pattern: the former is for registering stuff in the collection prior to service provider/app build, the latter to configure/start injected services on top of the added services.
1 parent 6f46b0d commit d995248

File tree

11 files changed

+1301
-54
lines changed

11 files changed

+1301
-54
lines changed

.netconfig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,8 @@
159159
sha = 666a2a7c315f72199c418f11482a950fc69a8901
160160
etag = 91ea15c07bfd784036c6ca931f5b2df7e9767b8367146d96c79caef09d63899f
161161
weak
162+
[file "src/Agents/Extensions/Throw.cs"]
163+
url = https://github.com/devlooped/catbag/blob/main/System/Throw.cs
164+
sha = 3012d56be7554c483e5c5d277144c063969cada9
165+
etag = 43c81c6c6dcdf5baee40a9e3edc5e871e473e6c954c901b82bb87a3a48888ea0
166+
weak
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using Devlooped.Extensions.AI;
2+
using Microsoft.Agents.AI;
3+
using Microsoft.Agents.AI.Hosting;
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Hosting;
7+
8+
namespace Devlooped.Agents.AI;
9+
10+
public static class AddAIAgentsExtensions
11+
{
12+
public static IHostApplicationBuilder AddAIAgents(this IHostApplicationBuilder builder, Action<string, AIAgentBuilder>? configurePipeline = default, Action<string, ChatClientAgentOptions>? configureOptions = default, string prefix = "ai:agents")
13+
{
14+
builder.AddChatClients();
15+
16+
foreach (var entry in builder.Configuration.AsEnumerable().Where(x =>
17+
x.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
18+
x.Key.EndsWith("client", StringComparison.OrdinalIgnoreCase)))
19+
{
20+
var section = string.Join(':', entry.Key.Split(':')[..^1]);
21+
// key == name (unlike chat clients, the AddAIAgent expects the key to be the name).
22+
var name = builder.Configuration[$"{section}:name"] ?? section[(prefix.Length + 1)..];
23+
24+
var options = builder.Configuration.GetRequiredSection(section).Get<ChatClientAgentOptions>();
25+
// We need logging set up for the configurable client to log changes
26+
builder.Services.AddLogging();
27+
28+
builder.AddAIAgent(name, (sp, key) =>
29+
{
30+
var agent = new ConfigurableAIAgent(sp, section, key, configureOptions);
31+
32+
if (configurePipeline is not null)
33+
{
34+
var builder = agent.AsBuilder();
35+
configurePipeline(key, builder);
36+
return builder.Build(sp);
37+
}
38+
39+
return agent;
40+
});
41+
}
42+
43+
return builder;
44+
}
45+
}

src/Agents/Agents.csproj

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFrameworks>netstandard2.0;net8.0;net9.0;net10.0</TargetFrameworks>
4+
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
55
<LangVersion>Preview</LangVersion>
6-
<PackageId>Devlooped.Agents.AI</PackageId>
6+
<AssemblyName>Devlooped.Agents.AI</AssemblyName>
7+
<RootNamespace>$(AssemblyName)</RootNamespace>
8+
<PackageId>$(AssemblyName)</PackageId>
79
<Description>Extensions for Microsoft.Agents.AI</Description>
810
<PackageLicenseExpression></PackageLicenseExpression>
911
<PackageLicenseFile>OSMFEULA.txt</PackageLicenseFile>
@@ -13,8 +15,10 @@
1315

1416
<ItemGroup>
1517
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" />
18+
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.9" />
1619
<PackageReference Include="NuGetizer" Version="1.3.1" PrivateAssets="all" />
1720
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-preview.251009.1" />
21+
<PackageReference Include="Microsoft.Agents.AI.Hosting" Version="1.0.0-preview.251009.1" />
1822
<PackageReference Include="Microsoft.Agents.AI.AzureAI" Version="1.0.0-preview.251009.1" />
1923
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251009.1" />
2024
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.9" />
@@ -25,4 +29,8 @@
2529
<None Include="..\..\osmfeula.txt" Link="osmfeula.txt" PackagePath="OSMFEULA.txt" />
2630
</ItemGroup>
2731

32+
<ItemGroup>
33+
<ProjectReference Include="..\Extensions\Extensions.csproj" />
34+
</ItemGroup>
35+
2836
</Project>

src/Agents/ConfigurableAIAgent.cs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
using System.Text.Json;
2+
using Microsoft.Agents.AI;
3+
using Microsoft.Extensions.AI;
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace Devlooped.Agents.AI;
9+
10+
public sealed partial class ConfigurableAIAgent : AIAgent, IDisposable
11+
{
12+
readonly IServiceProvider services;
13+
readonly IConfiguration configuration;
14+
readonly string section;
15+
readonly string name;
16+
readonly ILogger logger;
17+
readonly Action<string, ChatClientAgentOptions>? configure;
18+
IDisposable reloadToken;
19+
ChatClientAgent agent;
20+
IChatClient chat;
21+
ChatClientAgentOptions options;
22+
23+
public ConfigurableAIAgent(IServiceProvider services, string section, string name, Action<string, ChatClientAgentOptions>? configure)
24+
{
25+
if (section.Contains('.'))
26+
throw new ArgumentException("Section separator must be ':', not '.'");
27+
28+
this.services = Throw.IfNull(services);
29+
this.configuration = services.GetRequiredService<IConfiguration>();
30+
this.logger = services.GetRequiredService<ILogger<ConfigurableAIAgent>>();
31+
this.section = Throw.IfNullOrEmpty(section);
32+
this.name = Throw.IfNullOrEmpty(name);
33+
this.configure = configure;
34+
35+
(agent, options, chat) = Configure(configuration.GetRequiredSection(section));
36+
reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null);
37+
}
38+
39+
public void Dispose() => reloadToken?.Dispose();
40+
41+
public override object? GetService(Type serviceType, object? serviceKey = null) => serviceType switch
42+
{
43+
Type t when t == typeof(ChatClientAgentOptions) => options,
44+
Type t when t == typeof(IChatClient) => chat,
45+
_ => agent.GetService(serviceType, serviceKey)
46+
};
47+
48+
public override string Id => agent.Id;
49+
public override string? Description => agent.Description;
50+
public override string DisplayName => agent.DisplayName;
51+
public override string? Name => agent.Name;
52+
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
53+
=> agent.DeserializeThread(serializedThread, jsonSerializerOptions);
54+
public override AgentThread GetNewThread() => agent.GetNewThread();
55+
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
56+
=> agent.RunAsync(messages, thread, options, cancellationToken);
57+
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
58+
=> agent.RunStreamingAsync(messages, thread, options, cancellationToken);
59+
60+
(ChatClientAgent, ChatClientAgentOptions, IChatClient) Configure(IConfigurationSection configSection)
61+
{
62+
var options = configSection.Get<AgentClientOptions>();
63+
options?.Name ??= name;
64+
65+
// If there was a custom id, we must validate it didn't change since that's not supported.
66+
if (configuration[$"{section}:name"] is { } newname && newname != name)
67+
throw new InvalidOperationException($"The name of a configured agent cannot be changed at runtime. Expected '{name}' but was '{newname}'.");
68+
69+
var client = services.GetRequiredKeyedService<IChatClient>(options?.Client
70+
?? throw new InvalidOperationException($"A client must be specified for agent '{name}' in configuration section '{section}'."));
71+
72+
var chat = configSection.GetSection("options").Get<ChatOptions>();
73+
if (chat is not null)
74+
options.ChatOptions = chat;
75+
76+
configure?.Invoke(name, options);
77+
78+
LogConfigured(name);
79+
80+
return (new ChatClientAgent(client, options, services.GetRequiredService<ILoggerFactory>(), services), options, client);
81+
}
82+
83+
void OnReload(object? state)
84+
{
85+
var configSection = configuration.GetRequiredSection(section);
86+
reloadToken?.Dispose();
87+
chat?.Dispose();
88+
(agent, options, chat) = Configure(configSection);
89+
reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null);
90+
}
91+
92+
[LoggerMessage(LogLevel.Information, "AIAgent '{Id}' configured.")]
93+
private partial void LogConfigured(string id);
94+
95+
class AgentClientOptions : ChatClientAgentOptions
96+
{
97+
public required string Client { get; set; }
98+
}
99+
}

0 commit comments

Comments
 (0)