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
29 changes: 23 additions & 6 deletions src/Agents/ConfigurableAIAgent.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Diagnostics;
using System.Text.Json;
using Devlooped.Extensions.AI;
using Devlooped.Extensions.AI.Grok;
using Microsoft.Agents.AI;
Expand All @@ -23,8 +24,9 @@ public sealed partial class ConfigurableAIAgent : AIAgent, IDisposable
readonly Action<string, ChatClientAgentOptions>? configure;
IDisposable reloadToken;
ChatClientAgent agent;
IChatClient chat;
ChatClientAgentOptions options;
IChatClient chat;
AIAgentMetadata metadata;

public ConfigurableAIAgent(IServiceProvider services, string section, string name, Action<string, ChatClientAgentOptions>? configure)
{
Expand All @@ -38,7 +40,7 @@ public ConfigurableAIAgent(IServiceProvider services, string section, string nam
this.name = Throw.IfNullOrEmpty(name);
this.configure = configure;

(agent, options, chat) = Configure(configuration.GetRequiredSection(section));
(agent, options, chat, metadata) = Configure(configuration.GetRequiredSection(section));
reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null);
}

Expand All @@ -50,6 +52,7 @@ public ConfigurableAIAgent(IServiceProvider services, string section, string nam
{
Type t when t == typeof(ChatClientAgentOptions) => options,
Type t when t == typeof(IChatClient) => chat,
Type t when typeof(AIAgentMetadata).IsAssignableFrom(t) => metadata,
_ => agent.GetService(serviceType, serviceKey)
};

Expand Down Expand Up @@ -78,7 +81,7 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum
/// </summary>
public ChatClientAgentOptions Options => options;

(ChatClientAgent, ChatClientAgentOptions, IChatClient) Configure(IConfigurationSection configSection)
(ChatClientAgent, ChatClientAgentOptions, IChatClient, AIAgentMetadata) Configure(IConfigurationSection configSection)
{
var options = configSection.Get<AgentClientOptions>();
options?.Name ??= name;
Expand Down Expand Up @@ -124,15 +127,18 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum

LogConfigured(name);

return (new ChatClientAgent(client, options, services.GetRequiredService<ILoggerFactory>(), services), options, client);
var agent = new ChatClientAgent(client, options, services.GetRequiredService<ILoggerFactory>(), services);
var metadata = agent.GetService<AIAgentMetadata>() ?? new AIAgentMetadata(provider);

return (agent, options, client, new ConfigurableAIAgentMetadata(name, section, metadata.ProviderName));
}

void OnReload(object? state)
{
var configSection = configuration.GetRequiredSection(section);
reloadToken?.Dispose();
chat?.Dispose();
(agent, options, chat) = Configure(configSection);
(agent, options, chat, metadata) = Configure(configSection);
reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null);
}

Expand All @@ -143,4 +149,15 @@ internal class AgentClientOptions : ChatClientAgentOptions
{
public string? Client { get; set; }
}
}

/// <summary>Metadata for a <see cref="ConfigurableAIAgent"/>.</summary>

[DebuggerDisplay("Name = {Name}, Section = {ConfigurationSection}, ProviderName = {ProviderName}")]
public class ConfigurableAIAgentMetadata(string name, string configurationSection, string? providerName) : AIAgentMetadata(providerName)
{
/// <summary>Name of the agent.</summary>
public string Name => name;
/// <summary>Configuration section where the agent is defined.</summary>
public string ConfigurationSection = configurationSection;
}
35 changes: 27 additions & 8 deletions src/Extensions/ConfigurableChatClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ClientModel.Primitives;
using System;
using System.ClientModel.Primitives;
using System.ComponentModel;
using Azure;
using Azure.AI.Inference;
Expand All @@ -25,6 +26,7 @@
readonly Action<string, IChatClient>? configure;
IDisposable reloadToken;
IChatClient innerClient;
ChatClientMetadata metadata;
object? options;

/// <summary>
Expand All @@ -46,28 +48,33 @@
this.id = Throw.IfNullOrEmpty(id);
this.configure = configure;

innerClient = Configure(configuration.GetRequiredSection(section));
(innerClient, metadata) = Configure(configuration.GetRequiredSection(section));
reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null);
}

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

/// <inheritdoc/>
public object? GetService(Type serviceType, object? serviceKey = null) => serviceType switch
{
Type t when typeof(ChatClientMetadata).IsAssignableFrom(t) => metadata,
Type t when t == typeof(IChatClient) => this,
_ => innerClient.GetService(serviceType, serviceKey)
};

/// <inheritdoc/>
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> innerClient.GetResponseAsync(messages, options, cancellationToken);
/// <inheritdoc/>
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> innerClient.GetStreamingResponseAsync(messages, options, cancellationToken);
/// <inheritdoc/>
public object? GetService(Type serviceType, object? serviceKey = null)
=> innerClient.GetService(serviceType, serviceKey);

/// <summary>Exposes the optional <see cref="ClientPipelineOptions"/> configured for the client.</summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public object? Options => options;

IChatClient Configure(IConfigurationSection configSection)
(IChatClient, ChatClientMetadata) Configure(IConfigurationSection configSection)
{
var options = SetOptions<ConfigurableClientOptions>(configSection);
Throw.IfNullOrEmpty(options?.ModelId, $"{configSection}:modelid");
Expand Down Expand Up @@ -107,7 +114,9 @@

LogConfigured(id);

return client;
var metadata = client.GetService<ChatClientMetadata>() ?? new ChatClientMetadata(null, null, null);

return (client, new ConfigurableChatClientMetadata(id, section, metadata.ProviderName, metadata.ProviderUri, metadata.DefaultModelId));
}

TOptions? SetOptions<TOptions>(IConfigurationSection section) where TOptions : class
Expand All @@ -118,7 +127,7 @@
var t when t == typeof(ConfigurableInferenceOptions) => section.Get<ConfigurableInferenceOptions>() as TOptions,
var t when t == typeof(ConfigurableAzureOptions) => section.Get<ConfigurableAzureOptions>() as TOptions,
#pragma warning disable SYSLIB1104 // The target type for a binder call could not be determined
_ => section.Get<TOptions>()

Check warning on line 130 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 130 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 130 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 130 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 130 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 130 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 130 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 130 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 130 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 130 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 130 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 130 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)
#pragma warning restore SYSLIB1104 // The target type for a binder call could not be determined
};

Expand All @@ -133,7 +142,7 @@
(innerClient as IDisposable)?.Dispose();
reloadToken?.Dispose();

innerClient = Configure(configSection);
(innerClient, metadata) = Configure(configSection);

reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null);
}
Expand All @@ -158,4 +167,14 @@
public string? ApiKey { get; set; }
public string? ModelId { get; set; }
}
}

/// <summary>Metadata for a <see cref="ConfigurableChatClient"/>.</summary>
public class ConfigurableChatClientMetadata(string id, string configurationSection, string? providerName, Uri? providerUri, string? defaultModelId)
: ChatClientMetadata(providerName, providerUri, defaultModelId)
{
/// <summary>The unique identifier of the configurable client.</summary>
public string Id => id;
/// <summary>The configuration section used to configure the client.</summary>
public string ConfigurationSection => configurationSection;
}
24 changes: 24 additions & 0 deletions src/Tests/ConfigurableAgentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,30 @@ public void CanGetFromAlternativeKey()
Assert.Same(agent, app.Services.GetIAAgent("Bot"));
}

[Fact]
public void CanGetSectionAndIdFromMetadata()
{
var builder = new HostApplicationBuilder();

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",
});

builder.AddAIAgents();

var app = builder.Build();

var agent = app.Services.GetRequiredKeyedService<AIAgent>("bot");
var metadata = agent.GetService<ConfigurableAIAgentMetadata>();

Assert.NotNull(metadata);
Assert.Equal("bot", metadata.Name);
Assert.Equal("ai:agents:bot", metadata.ConfigurationSection);
}

[Fact]
public void DedentsDescriptionAndInstructions()
{
Expand Down
25 changes: 25 additions & 0 deletions src/Tests/ConfigurableClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,31 @@ public void CanGetFromAlternativeKey()
Assert.Same(grok, services.GetChatClient("grok"));
}

[Fact]
public void CanGetSectionAndIdFromMetadata()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ai:clients:Grok:id"] = "groked",
["ai:clients:Grok:modelid"] = "grok-4-fast",
["ai:clients:Grok:ApiKey"] = "xai-asdfasdf",
["ai:clients:Grok:endpoint"] = "https://api.x.ai",
})
.Build();

var services = new ServiceCollection()
.AddSingleton<IConfiguration>(configuration)
.AddChatClients(configuration)
.BuildServiceProvider();

var grok = services.GetRequiredKeyedService<IChatClient>("groked");
var metadata = grok.GetRequiredService<ConfigurableChatClientMetadata>();

Assert.Equal("groked", metadata.Id);
Assert.Equal("ai:clients:Grok", metadata.ConfigurationSection);
}

[Fact]
public void CanOverrideClientId()
{
Expand Down