diff --git a/AI.slnx b/AI.slnx
index b7219e9..f6dcf9d 100644
--- a/AI.slnx
+++ b/AI.slnx
@@ -7,5 +7,6 @@
+
diff --git a/readme.md b/readme.md
index 009b265..b4120b9 100644
--- a/readme.md
+++ b/readme.md
@@ -1,33 +1,141 @@
 Devlooped AI Extensions
============
-[](https://github.com//devlooped/AI/blob/main/license.txt)
-[](https://github.com/devlooped/AI/actions/workflows/build.yml)
+[](osmfeula.txt)
+[](license.txt)
Extensions for Microsoft.Agents.AI and Microsoft.Extensions.AI.
-## Open Source Maintenance Fee
+
-To ensure the long-term sustainability of this project, use of this project requires an
-[Open Source Maintenance Fee](https://opensourcemaintenancefee.org). While the source
-code is freely available under the terms of the [MIT License](./license.txt), all other aspects of the
-project --including opening or commenting on issues, participating in discussions and
-downloading releases-- require [adherence to the Maintenance Fee](./osmfeula.txt).
+# Devlooped.Agents.AI
-In short, if you use this project to generate revenue, the [Maintenance Fee is required](./osmfeula.txt).
+[](https://www.nuget.org/packages/Devlooped.Agents.AI)
+[](https://www.nuget.org/packages/Devlooped.Agents.AI)
-To pay the Maintenance Fee, [become a Sponsor](https://github.com/sponsors/devlooped).
+
+Extensions for Microsoft.Agents.AI, such as configuration-driven auto-reloading agents.
+
+
+
+## Overview
+
+Microsoft.Agents.AI (aka [Agent Framework](https://learn.microsoft.com/en-us/agent-framework/overview/agent-framework-overview)
+is a comprehensive API for building AI agents. Its programatic model (which follows closely
+the [Microsoft.Extensions.AI](https://learn.microsoft.com/en-us/dotnet/ai/microsoft-extensions-ai)
+approach) provides maximum flexibility with little prescriptive structure.
+
+This package provides additional extensions to make developing agents easier and more
+declarative.
+
+## Configurable Agents
+
+Tweaking agent options such as description, instructions, chat client to use and its
+options, etc. is very common during development/testing. This package provides the ability to
+drive those settings from configuration (with auto-reload support). This makes it far easier
+to experiment with various combinations of agent instructions, chat client providers and
+options, and model parameters without changing code, recompiling or even restarting the application:
+
+> [!NOTE]
+> This example shows integration with configurable chat clients feature from the
+> Devlooped.Extensions.AI package, but any `IChatClient` registered in the DI container
+> with a matching key can be used.
+
+```json
+{
+ "AI": {
+ "Agents": {
+ "MyAgent": {
+ "Description": "An AI agent that helps with customer support.",
+ "Instructions": "You are a helpful assistant for customer support.",
+ "Client": "Grok",
+ "Options": {
+ "ModelId": "grok-4",
+ "Temperature": 0.5,
+ }
+ }
+ },
+ "Clients": {
+ "Grok": {
+ "Endpoint": "https://api.grok.ai/v1",
+ "ModelId": "grok-4-fast-non-reasoning",
+ "ApiKey": "xai-asdf"
+ }
+ }
+ }
+}
+````
+
+```csharp
+var host = new HostApplicationBuilder(args);
+host.Configuration.AddJsonFile("appsettings.json, optional: false, reloadOnChange: true);
+
+// 👇 implicitly calls AddChatClients
+host.AddAIAgents();
+
+var app = host.Build();
+var agent = app.Services.GetRequiredKeyedService("MyAgent");
+```
+
+Agents are also properly registered in the corresponding Microsoft Agent Framework
+[AgentCatalog](https://learn.microsoft.com/en-us/dotnet/api/microsoft.agents.ai.hosting.agentcatalog):
+
+```csharp
+var catalog = app.Services.GetRequiredService();
+await foreach (AIAgent agent in catalog.GetAgentsAsync())
+{
+ var metadata = agent.GetService();
+ Console.WriteLine($"Agent: {agent.Name} by {metadata.ProviderName}");
+}
+```
+
+
# Devlooped.Extensions.AI
[](https://www.nuget.org/packages/Devlooped.Extensions.AI)
[](https://www.nuget.org/packages/Devlooped.Extensions.AI)
-
+
Extensions for Microsoft.Extensions.AI
-
+
+
+
+## Configurable Chat Clients
+
+Since tweaking chat options such as model identifier, reasoning effort, verbosity
+and other model settings is very common, this package provides the ability to
+drive those settings from configuration (with auto-reload support), both per-client
+as well as per-request. This makes local development and testing much easier and
+boosts the dev loop:
+
+```json
+{
+ "AI": {
+ "Clients": {
+ "Grok": {
+ "Endpoint": "https://api.grok.ai/v1",
+ "ModelId": "grok-4-fast-non-reasoning",
+ "ApiKey": "xai-asdf"
+ }
+ }
+ }
+}
+````
+
+```csharp
+var host = new HostApplicationBuilder(args);
+host.Configuration.AddJsonFile("appsettings.json, optional: false, reloadOnChange: true);
+host.AddChatClients();
+
+var app = host.Build();
+var grok = app.Services.GetRequiredKeyedService("Grok");
+```
+
+Changing the `appsettings.json` file will automatically update the client
+configuration without restarting the application.
+
-
## Grok
Full support for Grok [Live Search](https://docs.x.ai/docs/guides/live-search)
@@ -332,7 +440,7 @@ IChatClient client = new GrokChatClient(Environment.GetEnvironmentVariable("XAI_
})
.Build();
```
-
+
# Sponsors
diff --git a/src/Agents/ConfigurableAIAgent.cs b/src/Agents/ConfigurableAIAgent.cs
index 3fa1c76..1a0fdd8 100644
--- a/src/Agents/ConfigurableAIAgent.cs
+++ b/src/Agents/ConfigurableAIAgent.cs
@@ -1,5 +1,4 @@
-using System.ComponentModel;
-using System.Text.Json;
+using System.Text.Json;
using Devlooped.Extensions.AI;
using Devlooped.Extensions.AI.Grok;
using Microsoft.Agents.AI;
@@ -61,7 +60,7 @@ public ConfigurableAIAgent(IServiceProvider services, string section, string nam
///
public override string DisplayName => agent.DisplayName;
///
- public override string? Name => this.name;
+ public override string? Name => name;
///
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> agent.DeserializeThread(serializedThread, jsonSerializerOptions);
@@ -74,6 +73,11 @@ public override Task RunAsync(IEnumerable message
public override IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> agent.RunStreamingAsync(messages, thread, options, cancellationToken);
+ ///
+ /// Configured agent options.
+ ///
+ public ChatClientAgentOptions Options => options;
+
(ChatClientAgent, ChatClientAgentOptions, IChatClient) Configure(IConfigurationSection configSection)
{
var options = configSection.Get();
diff --git a/src/Agents/readme.md b/src/Agents/readme.md
new file mode 100644
index 0000000..a3c40ed
--- /dev/null
+++ b/src/Agents/readme.md
@@ -0,0 +1,9 @@
+[](osmfeula.txt)
+[](license.txt)
+[](https://github.com/devlooped/AI)
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Extensions/AddChatClientsExtensions.cs b/src/Extensions/AddChatClientsExtensions.cs
index b25705f..84e1653 100644
--- a/src/Extensions/AddChatClientsExtensions.cs
+++ b/src/Extensions/AddChatClientsExtensions.cs
@@ -24,7 +24,8 @@ public static class AddChatClientsExtensions
/// 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")
+ public static TBuilder AddChatClients(this TBuilder builder, Action? configurePipeline = default, Action? configureClient = default, string prefix = "ai:clients")
+ where TBuilder : IHostApplicationBuilder
{
AddChatClients(builder.Services, builder.Configuration, configurePipeline, configureClient, prefix);
return builder;
diff --git a/src/Extensions/ConfigurableChatClient.cs b/src/Extensions/ConfigurableChatClient.cs
index a483190..a272440 100644
--- a/src/Extensions/ConfigurableChatClient.cs
+++ b/src/Extensions/ConfigurableChatClient.cs
@@ -1,6 +1,9 @@
-using Azure;
+using System.ClientModel.Primitives;
+using System.ComponentModel;
+using Azure;
using Azure.AI.Inference;
using Azure.AI.OpenAI;
+using Azure.Core;
using Devlooped.Extensions.AI.Grok;
using Devlooped.Extensions.AI.OpenAI;
using Microsoft.Extensions.AI;
@@ -14,7 +17,7 @@ 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
+public sealed partial class ConfigurableChatClient : IChatClient, IDisposable
{
readonly IConfiguration configuration;
readonly string section;
@@ -23,7 +26,7 @@ public sealed partial class ConfigurableChatClient : IDisposable, IChatClient
readonly Action? configure;
IDisposable reloadToken;
IChatClient innerClient;
-
+ object? options;
///
/// Initializes a new instance of the class.
@@ -61,9 +64,13 @@ public Task GetResponseAsync(IEnumerable messages, Ch
public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> innerClient.GetStreamingResponseAsync(messages, options, cancellationToken);
+ /// Exposes the optional configured for the client.
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public object? Options => options;
+
IChatClient Configure(IConfigurationSection configSection)
{
- var options = configSection.Get();
+ var options = SetOptions(configSection);
Throw.IfNullOrEmpty(options?.ModelId, $"{configSection}:modelid");
// If there was a custom id, we must validate it didn't change since that's not supported.
@@ -92,9 +99,9 @@ IChatClient Configure(IConfigurationSection configSection)
IChatClient client = options.Endpoint?.Host == "api.x.ai"
? new GrokChatClient(apikey, options.ModelId, options)
: options.Endpoint?.Host == "ai.azure.com"
- ? new ChatCompletionsClient(options.Endpoint, new AzureKeyCredential(apikey), configSection.Get()).AsIChatClient(options.ModelId)
+ ? new ChatCompletionsClient(options.Endpoint, new AzureKeyCredential(apikey), SetOptions(configSection)).AsIChatClient(options.ModelId)
: options.Endpoint?.Host.EndsWith("openai.azure.com") == true
- ? new AzureOpenAIChatClient(options.Endpoint, new AzureKeyCredential(apikey), options.ModelId, configSection.Get())
+ ? new AzureOpenAIChatClient(options.Endpoint, new AzureKeyCredential(apikey), options.ModelId, SetOptions(configSection))
: new OpenAIChatClient(apikey, options.ModelId, options);
configure?.Invoke(id, client);
@@ -104,6 +111,22 @@ IChatClient Configure(IConfigurationSection configSection)
return client;
}
+ TOptions? SetOptions(IConfigurationSection section) where TOptions : class
+ {
+ var options = typeof(TOptions) switch
+ {
+ var t when t == typeof(ConfigurableClientOptions) => section.Get() as TOptions,
+ var t when t == typeof(ConfigurableInferenceOptions) => section.Get() as TOptions,
+ var t when t == typeof(ConfigurableAzureOptions) => section.Get() as TOptions,
+#pragma warning disable SYSLIB1104 // The target type for a binder call could not be determined
+ _ => section.Get()
+#pragma warning restore SYSLIB1104 // The target type for a binder call could not be determined
+ };
+
+ this.options = options;
+ return options;
+ }
+
void OnReload(object? state)
{
var configSection = configuration.GetRequiredSection(section);
diff --git a/src/Extensions/Devlooped.Extensions.AI.targets b/src/Extensions/Devlooped.Extensions.AI.targets
index a781867..1ba0856 100644
--- a/src/Extensions/Devlooped.Extensions.AI.targets
+++ b/src/Extensions/Devlooped.Extensions.AI.targets
@@ -1,7 +1,7 @@
true
- 10.0.100-preview.7.25380.108
+ 10.0.100-rc.2.25502.107
diff --git a/src/Extensions/Extensions.csproj b/src/Extensions/Extensions.csproj
index 7be9277..7a5a3a2 100644
--- a/src/Extensions/Extensions.csproj
+++ b/src/Extensions/Extensions.csproj
@@ -44,9 +44,22 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
-
+
\ No newline at end of file
diff --git a/src/Extensions/readme.md b/src/Extensions/readme.md
index c10a0f5..8c26b48 100644
--- a/src/Extensions/readme.md
+++ b/src/Extensions/readme.md
@@ -1,17 +1,9 @@
-
-## Open Source Maintenance Fee
+[](osmfeula.txt)
+[](license.txt)
+[](https://github.com/devlooped/AI)
-To ensure the long-term sustainability of this project, use of `Devlooped.Extensions.AI` requires an
-[Open Source Maintenance Fee](https://opensourcemaintenancefee.org). While the source
-code is freely available under the terms of the
-[MIT License](https://github.com/devlooped/Extensions.AI/blob/main/license.txt),
-this package and other aspects of the project require
-[adherence to the Maintenance Fee](https://github.com/devlooped/Extensions.AI/blob/main/osmfeula.txt).
-
-In short, if you use this project to generate revenue, the [Maintenance Fee is required](https://github.com/devlooped/Extensions.AI/blob/main/osmfeula.txt).
-
-To pay the Maintenance Fee, [become a Sponsor](https://github.com/sponsors/devlooped).
-
-
+
+
+
\ No newline at end of file
diff --git a/src/SampleChat/AppInitializer.cs b/src/SampleChat/AppInitializer.cs
new file mode 100644
index 0000000..4324e89
--- /dev/null
+++ b/src/SampleChat/AppInitializer.cs
@@ -0,0 +1,23 @@
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace SampleChat;
+
+class AppInitializer
+{
+ [ModuleInitializer]
+ public static void Init()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ Console.InputEncoding = Console.OutputEncoding = Encoding.UTF8;
+
+ // Load environment variables from .env files in current dir and above.
+ DotNetEnv.Env.TraversePath().Load();
+
+ // Load environment variables from user profile directory.
+ var userEnv = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".env");
+ if (File.Exists(userEnv))
+ DotNetEnv.Env.Load(userEnv);
+ }
+}
diff --git a/src/SampleChat/Program.cs b/src/SampleChat/Program.cs
new file mode 100644
index 0000000..453834f
--- /dev/null
+++ b/src/SampleChat/Program.cs
@@ -0,0 +1,88 @@
+using Devlooped.Agents.AI;
+using Devlooped.Extensions.AI;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Newtonsoft.Json;
+using Spectre.Console;
+using Spectre.Console.Json;
+using Tomlyn.Extensions.Configuration;
+
+var host = new HostApplicationBuilder(args);
+
+#if DEBUG
+host.Environment.EnvironmentName = Environments.Development;
+#endif
+
+// Setup config files from output directory vs project directory
+// depending on environment, so we can reload by editing the source
+if (host.Environment.IsProduction())
+{
+ foreach (var json in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.json", SearchOption.AllDirectories))
+ host.Configuration.AddJsonFile(json, optional: false, reloadOnChange: true);
+
+ foreach (var toml in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.toml", SearchOption.AllDirectories))
+ host.Configuration.AddTomlFile(toml, optional: false, reloadOnChange: true);
+}
+else
+{
+ var baseDir = ThisAssembly.Project.MSBuildProjectDirectory;
+ var outDir = Path.Combine(baseDir, ThisAssembly.Project.BaseOutputPath);
+ var objDir = Path.Combine(baseDir, ThisAssembly.Project.BaseIntermediateOutputPath);
+
+ bool IsSource(string path) => !path.StartsWith(outDir) && !path.StartsWith(objDir);
+
+ foreach (var json in Directory.EnumerateFiles(baseDir, "*.json", SearchOption.AllDirectories).Where(IsSource))
+ host.Configuration.AddJsonFile(json, optional: false, reloadOnChange: true);
+
+ foreach (var toml in Directory.EnumerateFiles(baseDir, "*.toml", SearchOption.AllDirectories).Where(IsSource))
+ host.Configuration.AddTomlFile(toml, optional: false, reloadOnChange: true);
+}
+
+// .env/secrets override other config, which may contain dummy API keys, for example
+host.Configuration
+ .AddEnvironmentVariables()
+ .AddUserSecrets();
+
+// 👇 implicitly calls AddChatClients
+host.AddAIAgents();
+
+var app = host.Build();
+var catalog = app.Services.GetRequiredService();
+var settings = new JsonSerializerSettings
+{
+ NullValueHandling = NullValueHandling.Include,
+ DefaultValueHandling = DefaultValueHandling.Ignore
+};
+
+// List configured clients
+foreach (var description in host.Services.AsEnumerable().Where(x => x.ServiceType == typeof(IChatClient) && x.IsKeyedService))
+{
+ var client = app.Services.GetKeyedService(description.ServiceKey);
+ if (client is null)
+ continue;
+
+ var metadata = client.GetService();
+ var chatopt = (client as ConfigurableChatClient)?.Options;
+
+ AnsiConsole.Write(new Panel(new JsonText(JsonConvert.SerializeObject(new { Metadata = metadata, Options = chatopt }, settings)))
+ {
+ Header = new PanelHeader($"| 💬 {description.ServiceKey} |"),
+ });
+}
+
+// List configured agents
+await foreach (var agent in catalog.GetAgentsAsync())
+{
+ var metadata = agent.GetService();
+
+ AnsiConsole.Write(new Panel(new JsonText(JsonConvert.SerializeObject(new { Agent = agent, Metadata = metadata }, settings)))
+ {
+ Header = new PanelHeader($"| 🤖 {agent.DisplayName} |"),
+ });
+}
+
+Console.ReadLine();
diff --git a/src/SampleChat/SampleChat.csproj b/src/SampleChat/SampleChat.csproj
new file mode 100644
index 0000000..b632474
--- /dev/null
+++ b/src/SampleChat/SampleChat.csproj
@@ -0,0 +1,36 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SampleChat/ai.toml b/src/SampleChat/ai.toml
new file mode 100644
index 0000000..24ef42f
--- /dev/null
+++ b/src/SampleChat/ai.toml
@@ -0,0 +1,18 @@
+[ai.clients.openai]
+modelid = "gpt-4.1"
+apikey = "sk-asdf"
+
+[ai.agents.reminder]
+name = "Orders"
+description = "Manage orders using catalogs for food or any other item."
+instructions = """
+ You are an AI agent responsible for processing orders for food or other items.
+ Your primary goals are to identify user intent, extract or request provider information, manage order data using tools and friendly responses to guide users through the ordering process.
+ """
+
+# ai.clients.openai, can omit the ai.clients prefix
+client = "openai"
+
+[ai.agents.reminder.options]
+modelid = "gpt-4o-mini"
+# additional properties could be added here
diff --git a/src/SampleChat/appsettings.json b/src/SampleChat/appsettings.json
new file mode 100644
index 0000000..8c76f9d
--- /dev/null
+++ b/src/SampleChat/appsettings.json
@@ -0,0 +1,21 @@
+{
+ "AI": {
+ "Agents": {
+ "Notes": {
+ "Description": "Provides free-form memory",
+ "Instructions": "You organize and keep notes for the user, using JSON-LD",
+ "Client": "Grok",
+ "Options": {
+ "ModelId": "grok-4"
+ }
+ }
+ },
+ "Clients": {
+ "Grok": {
+ "Endpoint": "https://api.grok.ai/v1",
+ "ModelId": "grok-4-fast-non-reasoning",
+ "ApiKey": "xai-asdf"
+ }
+ }
+ }
+}
\ No newline at end of file