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 @@ ![Icon](assets/img/icon-32.png) Devlooped AI Extensions ============ -[![License](https://img.shields.io/github/license/devlooped/AI.svg?color=blue)](https://github.com//devlooped/AI/blob/main/license.txt) -[![Build](https://github.com/devlooped/AI/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/devlooped/AI/actions/workflows/build.yml) +[![EULA](https://img.shields.io/badge/EULA-OSMF-blue?labelColor=black&color=C9FF30)](osmfeula.txt) +[![OSS](https://img.shields.io/github/license/devlooped/oss.svg?color=blue)](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). +[![Version](https://img.shields.io/nuget/vpre/Devlooped.Agents.AI.svg?color=royalblue)](https://www.nuget.org/packages/Devlooped.Agents.AI) +[![Downloads](https://img.shields.io/nuget/dt/Devlooped.Agents.AI.svg?color=green)](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 [![Version](https://img.shields.io/nuget/vpre/Devlooped.Extensions.AI.svg?color=royalblue)](https://www.nuget.org/packages/Devlooped.Extensions.AI) [![Downloads](https://img.shields.io/nuget/dt/Devlooped.Extensions.AI.svg?color=green)](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 @@ +[![EULA](https://img.shields.io/badge/EULA-OSMF-blue?labelColor=black&color=C9FF30)](osmfeula.txt) +[![OSS](https://img.shields.io/github/license/devlooped/oss.svg?color=blue)](license.txt) +[![GitHub](https://img.shields.io/badge/-source-181717.svg?logo=GitHub)](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 +[![EULA](https://img.shields.io/badge/EULA-OSMF-blue?labelColor=black&color=C9FF30)](osmfeula.txt) +[![OSS](https://img.shields.io/github/license/devlooped/oss.svg?color=blue)](license.txt) +[![GitHub](https://img.shields.io/badge/-source-181717.svg?logo=GitHub)](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