diff --git a/AI.slnx b/AI.slnx index f5d49a4..0e6ed7b 100644 --- a/AI.slnx +++ b/AI.slnx @@ -4,12 +4,6 @@ - - - - - - diff --git a/readme.md b/readme.md index 4fabec2..a12f018 100644 --- a/readme.md +++ b/readme.md @@ -3,8 +3,10 @@ [![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) +[![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.Agents.AI and Microsoft.Extensions.AI. +Extensions for Microsoft.Extensions.AI. ## Open Source Maintenance Fee @@ -19,270 +21,6 @@ OSMF tier. A single fee covers all of [Devlooped packages](https://www.nuget.org -# Devlooped.Agents.AI - -[![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) - - -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}"); -} -``` - -You can of course use any config format supported by .NET configuration, such as -TOML which is arguably more human-friendly for hand-editing: - -```toml -[ai.clients.openai] -modelid = "gpt-4.1" - -[ai.clients.grok] -endpoint = "https://api.x.ai/v1" -modelid = "grok-4-fast-non-reasoning" - -[ai.agents.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.orders.options] -modelid = "gpt-4o-mini" -``` - -This can be used by leveraging [Tomlyn.Extensions.Configuration](https://www.nuget.org/packages/Tomlyn.Extensions.Configuration). - -> [!NOTE] -> This package will automatically dedent and trim start and end newlines from -> multi-line instructions and descriptions when applying the configuration, -> avoiding unnecessary tokens being used for indentation while allowing flexible -> formatting in the config file. - -You can also leverage the format pioneered by [VS Code Chat Modes](https://code.visualstudio.com/docs/copilot/customization/custom-chat-modes), - (por "custom agents") by using markdown format plus YAML front-matter for better readability: - -```yaml ---- -id: ai.agents.notes -description: Provides free-form memory -client: grok -model: grok-4-fast ---- -You organize and keep notes for the user. -# Some header -More content -``` - -Visual Studio Code will ignore the additional attributes used by this project. In particular, the `model` -property is a shorthand for setting the `options.modelid`, but in our implementation, the latter takes -precedence over the former, which allows you to rely on `model` to drive the VSCode testing, and the -longer form for run-time with the Agents Framework: - -```yaml ---- -id: ai.agents.notes -description: Provides free-form memory -model: Grok Code Fast 1 (copilot) -client: grok -options: - modelid: grok-code-fast-1 ---- -// Instructions -``` - -![agent model picker](assets/img/agent-model.png) - -Use the provided `AddAgentMarkdown` extension method to load instructions from files as follows: - -```csharp -var host = new HostApplicationBuilder(args); -host.Configuration.AddAgentMarkdown("notes.agent.md", optional: false, reloadOnChange: true); -``` - -The `id` field in the front-matter is required and specifies the configuration section name, and -all other fields are added as if they were specified under it in the configuration. - -### Extensible AI Contexts - -The Microsoft [agent framework](https://learn.microsoft.com/en-us/agent-framework/overview/agent-framework-overview) allows extending -agents with dynamic context via [AIContextProvider](https://learn.microsoft.com/en-us/dotnet/api/microsoft.agents.ai.aicontextprovider) -and `AIContext`. This package supports dynamic extension of a configured agent in the following ways (in order of priority): - -1. A keyed service `AIContextProviderFactory` with the same name as the agent will be set up just as if you had - set it manually as the [ChatClientAgentOptions.AIContextProviderFactory](https://learn.microsoft.com/en-us/dotnet/api/microsoft.agents.ai.chatclientagentoptions.aicontextproviderfactory) - in code. -2. Aggregate of AI contexts from: - a. Keyed service `AIContextProvider` with the same name as the agent. - b. Keyed service `AIContext` with the same name as the agent. - c. Other services pulled in via `use` setting for an agent registered as either `AIContextProvider` or `AIContext` - with a matching key. - -The first option assumes you want full control of the context, so it does not allow futher composition. -The second alternative allows more declarative scenarios involving reusable and cross-cutting -context definitions. - -For example, let's say you want to provide consistent tone for all your agents. It would be tedious, repetitive and harder -to maintain if you had to set that in each agent's instructions. Instead, you can define a reusable context named `tone` such as: - -```toml -[ai.context.tone] -instructions = """\ - Default to using spanish language, using argentinean "voseo" in your responses \ - (unless the user explicitly talks in a different language). \ - This means using "vos" instead of "tú" and conjugating verbs accordingly. \ - Don't use the expression "pa'" instead of "para". Don't mention the word "voseo". - """ -``` - -Then, you can reference that context in any agent using the `use` setting: -```toml -[ai.agents.support] -description = "An AI agent that helps with customer support." -instructions = "..." -client = "grok" -use = ["tone"] - -[ai.agents.sales] -description = "An AI agent that helps with sales inquiries." -instructions = "..." -client = "openai" -use = ["tone"] -``` - -Configured contexts can provide all three components of an `AIContext`: instructions, messages and tools, such as: - -```toml -[ai.context.timezone] -instructions = "Always assume the user's timezone is America/Argentina/Buenos_Aires unless specified otherwise." -messages = [ - { system = "You are aware of the current date and time in America/Argentina/Buenos_Aires." } -] -tools = ["get_date"] -``` - -If multiple contexts are specified in `use`, they are applied in order, concatenating their instructions, messages and tools. - -In addition to configured sections, the `use` property can also reference exported contexts as either `AIContext` -(for static context) or `AIContextProvider` (for dynamic context) registered in DI with a matching name. - - -### Extensible Tools - -The `tools` section allows specifying tool names registered in the DI container, such as: - -```csharp -services.AddKeyedSingleton("get_date", AIFunctionFactory.Create(() => DateTimeOffset.Now, "get_date")); -``` - -This tool will be automatically wired into any agent that uses the `timezone` context above. - -Agents themselves can also add tools from DI into an agent's context without having to define an entire -section just for that, by specifying the tool name directly in the `tools` array: - -```toml -[ai.agents.support] -description = "An AI agent that helps with customer support." -instructions = "..." -client = "grok" -use = ["tone"] -tools = ["get_date"] -``` - -This enables a flexible and convenient mix of static and dynamic context for agents, all driven -from configuration. - -In addition to registering your own tools in DI, you can also use leverage the MCP C# SDK and reuse -the same tool declarations: - -```csharp -builder.Services.AddMcpServer().WithTools(); - -// 👇 Reuse same tool definitions in agents -builder.AddAIAgents().WithTools(); -``` - - - -# 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 diff --git a/sample/Aspire/AppHost.cs b/sample/Aspire/AppHost.cs deleted file mode 100644 index 475252b..0000000 --- a/sample/Aspire/AppHost.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Projects; - -var builder = DistributedApplication.CreateBuilder(args); - -var server = builder.AddProject("server"); - -// For now, we can't really launch a console project and have its terminal shown. -// See https://github.com/dotnet/aspire/issues/8440 -//builder.AddProject("client") -// .WithReference(server) -// // Flow the resolved Server HTTP endpoint to the client config -// .WithEnvironment("ai__clients__chat__endpoint", server.GetEndpoint("http")) -// .WithExternalConsole(); - -builder.Build().Run(); diff --git a/sample/Aspire/Aspire.csproj b/sample/Aspire/Aspire.csproj deleted file mode 100644 index e19de01..0000000 --- a/sample/Aspire/Aspire.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - Exe - net10.0 - - - - - - - - - - - - diff --git a/sample/Aspire/Properties/launchSettings.json b/sample/Aspire/Properties/launchSettings.json deleted file mode 100644 index a433d15..0000000 --- a/sample/Aspire/Properties/launchSettings.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:17198;http://localhost:15055", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21263", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22169" - } - }, - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:15055", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19208", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20046" - } - } - } -} diff --git a/sample/Aspire/appsettings.Development.json b/sample/Aspire/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/sample/Aspire/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/sample/Aspire/appsettings.json b/sample/Aspire/appsettings.json deleted file mode 100644 index 31c092a..0000000 --- a/sample/Aspire/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Aspire.Hosting.Dcp": "Warning" - } - } -} diff --git a/sample/Client/Client.csproj b/sample/Client/Client.csproj deleted file mode 100644 index e7d215d..0000000 --- a/sample/Client/Client.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - Exe - net10.0 - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/sample/Client/Program.cs b/sample/Client/Program.cs deleted file mode 100644 index 5ae9364..0000000 --- a/sample/Client/Program.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Net.Http.Json; -using Devlooped.Extensions.AI.OpenAI; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; - -var builder = App.CreateBuilder(args); -#if DEBUG -builder.Environment.EnvironmentName = Environments.Development; -#endif - -builder.AddServiceDefaults(); -builder.Services.AddHttpClient() - .ConfigureHttpClientDefaults(b => b.AddStandardResilienceHandler()); - -var app = builder.Build(async (IServiceProvider services, CancellationToken cancellation) => -{ - var baseUrl = Environment.GetEnvironmentVariable("applicationUrl") ?? "http://localhost:5117"; - var http = services.GetRequiredService().CreateClient(); - var agents = await http.GetFromJsonAsync($"{baseUrl}/agents", cancellation) ?? []; - - if (agents.Length == 0) - { - AnsiConsole.MarkupLine(":warning: No agents available"); - return; - } - - var selectedAgent = AnsiConsole.Prompt(new SelectionPrompt() - .Title("Select agent:") - .UseConverter(a => $"{a.Name}: {a.Description ?? ""}") - .AddChoices(agents)); - - var chat = new OpenAIChatClient("none", "default", new OpenAI.OpenAIClientOptions - { - Endpoint = new Uri($"{baseUrl}/{selectedAgent.Name}/v1") - }).AsBuilder().UseOpenTelemetry().UseJsonConsoleLogging().Build(services); - - var history = new List(); - - AnsiConsole.MarkupLine($":robot: Ready"); - AnsiConsole.Markup($":person_beard: "); - while (!cancellation.IsCancellationRequested) - { - var input = Console.ReadLine()?.Trim(); - if (string.IsNullOrEmpty(input)) - continue; - - history.Add(new ChatMessage(ChatRole.User, input)); - try - { - var response = await AnsiConsole.Status().StartAsync(":robot: Thinking...", ctx => chat.GetResponseAsync(input)); - history.AddRange(response.Messages); - try - { - // Try rendering as formatted markup - if (response.Text is { Length: > 0 }) - AnsiConsole.MarkupLine($":robot: {response.Text}"); - } - catch (Exception) - { - // Fallback to escaped markup text if rendering fails - AnsiConsole.MarkupLineInterpolated($":robot: {response.Text}"); - } - AnsiConsole.Markup($":person_beard: "); - } - catch (Exception e) - { - AnsiConsole.WriteException(e); - } - } - - AnsiConsole.MarkupLine($":robot: Shutting down..."); -}); - -Console.WriteLine("Powered by Smith"); - -await app.RunAsync(); - -record AgentCard(string Name, string? Description); \ No newline at end of file diff --git a/sample/Client/Properties/launchSettings.json b/sample/Client/Properties/launchSettings.json deleted file mode 100644 index bc06646..0000000 --- a/sample/Client/Properties/launchSettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "profiles": { - "Client": { - "commandName": "Project", - "environmentVariables": { - "applicationUrl": "http://localhost:5117" - } - } - } -} \ No newline at end of file diff --git a/sample/Client/appsettings.json b/sample/Client/appsettings.json deleted file mode 100644 index 6a8dd9e..0000000 --- a/sample/Client/appsettings.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "AI": { - "Clients": { - "Chat": { - "ApiKey": "dev", - "ModelId": "default", - "Endpoint": "http://localhost:5117/notes/v1" - } - } - }, - "Logging": { - "LogLevel": { - "Default": "Warning", - "System.Net.Http.HttpClient": "Error", - "Microsoft.Hosting": "Error" - } - } -} \ No newline at end of file diff --git a/sample/Directory.Build.props b/sample/Directory.Build.props deleted file mode 100644 index 1ee4654..0000000 --- a/sample/Directory.Build.props +++ /dev/null @@ -1,7 +0,0 @@ - - - enable - enable - b420aaad-e6e3-43d7-8d91-5a07b19f20ab - - \ No newline at end of file diff --git a/sample/Directory.Build.targets b/sample/Directory.Build.targets deleted file mode 100644 index ddfc56d..0000000 --- a/sample/Directory.Build.targets +++ /dev/null @@ -1,22 +0,0 @@ - - - - $(DefineConstants);WEB - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/sample/Server/AgentDiscoveryExtensions.cs b/sample/Server/AgentDiscoveryExtensions.cs deleted file mode 100644 index 9e2e30b..0000000 --- a/sample/Server/AgentDiscoveryExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; -using Microsoft.Agents.AI.Hosting; - -static class AgentDiscoveryExtensions -{ - public static void MapAgentDiscovery(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string path) - { - var routeGroup = endpoints.MapGroup(path); - routeGroup.MapGet("/", async (AgentCatalog catalog, CancellationToken cancellation) - => Results.Ok(await catalog - .GetAgentsAsync(cancellation) - .Select(agent => new AgentDiscoveryCard(agent.Name!, agent.Description)) - .ToArrayAsync())) - .WithName("GetAgents"); - } - - record AgentDiscoveryCard(string Name, - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Description); -} diff --git a/sample/Server/ConsoleExtensions.cs b/sample/Server/ConsoleExtensions.cs deleted file mode 100644 index 018256c..0000000 --- a/sample/Server/ConsoleExtensions.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Devlooped.Agents.AI; -using Devlooped.Extensions.AI; -using Microsoft.Agents.AI.Hosting; -using Microsoft.Extensions.AI; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; -using Spectre.Console; -using Spectre.Console.Json; - -public static class ConsoleExtensions -{ - extension(IServiceProvider services) - { - public async ValueTask RenderAgentsAsync(IServiceCollection collection) - { - var catalog = services.GetRequiredService(); - var settings = new JsonSerializerSettings - { - NullValueHandling = NullValueHandling.Include, - DefaultValueHandling = DefaultValueHandling.Ignore, - ContractResolver = new IgnoreDelegatePropertiesResolver(), - }; - - // List configured clients - foreach (var description in collection.AsEnumerable().Where(x => x.ServiceType == typeof(IChatClient) && x.IsKeyedService && x.ServiceKey is string)) - { - var client = 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($"| 💬 {metadata?.Id} from {metadata?.ConfigurationSection} |"), - }); - } - - // 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} from {metadata?.ConfigurationSection} |"), - }); - } - } - } - - class IgnoreDelegatePropertiesResolver : DefaultContractResolver - { - protected override JsonProperty CreateProperty(System.Reflection.MemberInfo member, MemberSerialization memberSerialization) - { - var property = base.CreateProperty(member, memberSerialization); - - if (property.PropertyType != null && typeof(Delegate).IsAssignableFrom(property.PropertyType)) - { - property.ShouldSerialize = _ => false; - } - - return property; - } - } -} diff --git a/sample/Server/NotesTools.cs b/sample/Server/NotesTools.cs deleted file mode 100644 index f3931c3..0000000 --- a/sample/Server/NotesTools.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.Agents.AI; -using ModelContextProtocol.Server; - -public class NotesContextProvider(NotesTools notes) : AIContextProvider -{ - public override ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) - => ValueTask.FromResult(new AIContext - { - Instructions = - $""" - Your current state is: - - ${notes.GetNotes()} - - """ - }); -} - -[McpServerToolType] -public class NotesTools -{ - string notes = ""; - - [McpServerTool] - public string GetNotes() => notes; - - [McpServerTool] - public void SaveNotes(string notes) => this.notes = notes; -} diff --git a/sample/Server/Program.cs b/sample/Server/Program.cs deleted file mode 100644 index d0ce8cf..0000000 --- a/sample/Server/Program.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Runtime.InteropServices; -using System.Text; -using Devlooped.Extensions.AI; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting; -using Microsoft.Agents.AI.Hosting.OpenAI; -using Microsoft.Extensions.AI; -using Spectre.Console; - -var builder = WebApplication.CreateBuilder(args); - -#if DEBUG -builder.Environment.EnvironmentName = Environments.Development; -// Fixes console rendering when running from Visual Studio -if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - Console.InputEncoding = Console.OutputEncoding = Encoding.UTF8; -#endif - -builder.AddServiceDefaults(); -builder.ConfigureReload(); - -// 👇 showcases using dynamic AI context from configuration -builder.Services.AddKeyedSingleton("get_date", AIFunctionFactory.Create(() => DateTimeOffset.UtcNow, "get_date")); -// dummy ones for illustration -builder.Services.AddKeyedSingleton("create_order", AIFunctionFactory.Create(() => "OK", "create_order")); -builder.Services.AddKeyedSingleton("cancel_order", AIFunctionFactory.Create(() => "OK", "cancel_order")); - -builder.Services.AddKeyedSingleton("notes"); - -// 👇 seamless integration of MCP tools -//builder.Services.AddMcpServer().WithTools(); - -// 👇 implicitly calls AddChatClients -builder.AddAIAgents() - .WithTools(); - -var app = builder.Build(); - -// From ServiceDefaults.cs -app.MapDefaultEndpoints(); - -#if DEBUG -// 👇 render all configured agents -await app.Services.RenderAgentsAsync(builder.Services); -#endif - -// Map each agent's endpoints via response API -var catalog = app.Services.GetRequiredService(); -// List configured agents -await foreach (var agent in catalog.GetAgentsAsync()) -{ - if (agent.Name != null) - app.MapOpenAIResponses(agent.Name); -} - -// Map the agents HTTP endpoints -app.MapAgentDiscovery("/agents"); - -if (!app.Environment.IsProduction()) -{ - app.Lifetime.ApplicationStarted.Register(() => - { - var baseUrl = Environment.GetEnvironmentVariable("ASPNETCORE_URLS"); - AnsiConsole.MarkupLine("[orange1]Registered Routes:[/]"); - - var endpoints = ((IEndpointRouteBuilder)app).DataSources - .SelectMany(es => es.Endpoints) - .OfType() - .Where(e => e.RoutePattern.RawText != null) - .OrderBy(e => e.RoutePattern.RawText); - - foreach (var endpoint in endpoints) - { - var httpMethods = endpoint.Metadata - .OfType() - .SelectMany(m => m.HttpMethods) ?? []; - - var methods = httpMethods.Any() ? $"{string.Join(", ", httpMethods)}" : "ANY"; - - AnsiConsole.MarkupLineInterpolated($"[blue][[{methods}]][/] [lime][link={baseUrl}{endpoint.RoutePattern.RawText}]{endpoint.RoutePattern.RawText}[/][/]"); - } - }); -} - -app.Run(); \ No newline at end of file diff --git a/sample/Server/Properties/launchSettings.json b/sample/Server/Properties/launchSettings.json deleted file mode 100644 index 228344d..0000000 --- a/sample/Server/Properties/launchSettings.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "applicationUrl": "http://server.dev.localhost:5117", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/sample/Server/Server.csproj b/sample/Server/Server.csproj deleted file mode 100644 index d95b24a..0000000 --- a/sample/Server/Server.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net10.0 - true - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/sample/Server/ai.toml b/sample/Server/ai.toml deleted file mode 100644 index 5cbef20..0000000 --- a/sample/Server/ai.toml +++ /dev/null @@ -1,31 +0,0 @@ -[ai.clients.openai] -modelid = "gpt-4.1" - -[ai.clients.grok] -endpoint = "https://api.x.ai/v1" -modelid = "grok-4-fast-non-reasoning" - -[ai.agents.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. - """ -options = { modelid = "gpt-4o-mini" } -# 👇 alternative syntax to specify options -# [ai.agents.orders.options] -# modelid = "gpt-4o-mini" - -# ai.clients.openai, can omit the ai.clients prefix -client = "openai" -use = ["tone"] -tools = ["get_date", "create_order", "cancel_order"] - - -[ai.context.tone] -instructions = """\ - Default to using spanish language, using argentinean "voseo" in your responses \ - (unless the user explicitly talks in a different language). \ - This means using "vos" instead of "tú" and conjugating verbs accordingly. \ - Don't mention the word "voseo". - """ \ No newline at end of file diff --git a/sample/Server/appsettings.Development.json b/sample/Server/appsettings.Development.json deleted file mode 100644 index a284d64..0000000 --- a/sample/Server/appsettings.Development.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.Hosting": "Warning", - "System.Net.Http.HttpClient": "Warning" - } - } -} diff --git a/sample/Server/appsettings.json b/sample/Server/appsettings.json deleted file mode 100644 index ccfa333..0000000 --- a/sample/Server/appsettings.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "AI": { - "Clients": { - "Grok": { - "Endpoint": "https://api.x.ai/v1", - "ModelId": "grok-4-fast-non-reasoning" - } - } - }, - "OpenTelemetry:ConsoleExporter": true -} diff --git a/sample/Server/notes.agent.md b/sample/Server/notes.agent.md deleted file mode 100644 index b0ff16c..0000000 --- a/sample/Server/notes.agent.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -id: ai.agents.notes -description: Provides free-form memory -beta: true -visibility: unlisted -client: grok -model: grok-4-fast -use: ["tone", "notes"] -tools: ["get_notes", "save_notes", "get_date"] ---- -You organize and keep notes for the user. - -You extract key points from the user's input and store them as notes in the agent -state. You keep track of notes that reference external files too, adding corresponding -notes relating to them if the user sends more information about them. - -You use JSON-LD format to store notes, optimized for easy retrieval, filtering -or reasoning later. This can involve nested structures, lists, tags, categories, timestamps, -inferred relationships between nodes and entities, etc. As you collect more notes, you can -go back and update, merge or reorganize existing notes to improve their structure, cohesion -and usefulness for retrieval and querying. - -When storing relative times (like "last week" or "next week"), always convert them to absolute -dates or timestamps, so you can be precise when responding about them. - -You are NOT a general-purpose assistant that can answer questions or perform tasks unrelated -to note-taking and recalling notes. If the user asks you to do something outside of -note-taking, you should politely decline and remind them of your purpose. - -Never include technical details about the JSON format or the storage mechanism in your -responses. Just focus on the content of the notes and how they can help the user. - -When recalling information from notes, don't ask for follow-up questions or request -any more information. Just provide the information. diff --git a/sample/ServiceDefaults.cs b/sample/ServiceDefaults.cs deleted file mode 100644 index 16076d7..0000000 --- a/sample/ServiceDefaults.cs +++ /dev/null @@ -1,150 +0,0 @@ -using Devlooped.Agents.AI; -using DotNetEnv.Configuration; -using OpenTelemetry; -using OpenTelemetry.Metrics; -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; -using Tomlyn.Extensions.Configuration; - - - -#if WEB -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -#endif - -static class ConfigureOpenTelemetryExtensions -{ - const string HealthEndpointPath = "/health"; - const string AlivenessEndpointPath = "/alive"; - - public static TBuilder AddServiceDefaults(this TBuilder builder) - where TBuilder : IHostApplicationBuilder - { - builder.ConfigureOpenTelemetry(); - - // .env/secrets override other config, which may contain dummy API keys, for example - builder.Configuration - .AddDotNetEnv() - .AddEnvironmentVariables() - .AddUserSecrets(); - - builder.ConfigureReload(); - -#if WEB - builder.AddDefaultHealthChecks(); -#endif - - return builder; - } - - public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) - where TBuilder : IHostApplicationBuilder - { - var serviceName = builder.Environment.ApplicationName - ?? throw new InvalidOperationException("Application name is not set in the hosting environment."); - - builder.Services.AddOpenTelemetry() - .ConfigureResource(rb => rb.AddService(serviceName)) - .WithTracing(tracing => - { -#if WEB - tracing.AddAspNetCoreInstrumentation(tracing => - // Don't trace requests to the health endpoint to avoid filling the dashboard with noise - tracing.Filter = httpContext => - !(httpContext.Request.Path.StartsWithSegments(HealthEndpointPath) - || httpContext.Request.Path.StartsWithSegments(AlivenessEndpointPath))); -#endif - tracing.AddHttpClientInstrumentation(); - - // Only add console exporter if explicitly enabled in configuration - if (builder.Configuration.GetValue("OpenTelemetry:ConsoleExporter")) - tracing.AddConsoleExporter(); - }) - .WithMetrics(metrics => - { -#if WEB - metrics.AddAspNetCoreInstrumentation(); -#endif - metrics.AddRuntimeInstrumentation(); - metrics.AddHttpClientInstrumentation(); - }); - - - if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) - builder.Services.AddOpenTelemetry().UseOtlpExporter(); - - return builder; - } - - /// - /// Configures automatic configuration reload from either the build output directory (production) - /// or from the project directory (development). - /// - public static TBuilder ConfigureReload(this TBuilder builder) - where TBuilder : IHostApplicationBuilder - { - if (builder.Environment.IsProduction()) - { - foreach (var toml in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.toml", SearchOption.AllDirectories)) - builder.Configuration.AddTomlFile(toml, optional: false, reloadOnChange: true); - - foreach (var json in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.json", SearchOption.AllDirectories)) - builder.Configuration.AddJsonFile(json, optional: false, reloadOnChange: true); - - foreach (var md in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.md", SearchOption.AllDirectories)) - builder.Configuration.AddAgentMarkdown(md, 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); - - // Only use configs outside of bin/ and obj/ directories since we want reload to happen from source files not output files - bool IsSource(string path) => !path.StartsWith(outDir) && !path.StartsWith(objDir); - - foreach (var toml in Directory.EnumerateFiles(baseDir, "*.toml", SearchOption.AllDirectories).Where(IsSource)) - builder.Configuration.AddTomlFile(toml, optional: false, reloadOnChange: true); - - foreach (var json in Directory.EnumerateFiles(baseDir, "*.json", SearchOption.AllDirectories).Where(IsSource)) - builder.Configuration.AddJsonFile(json, optional: false, reloadOnChange: true); - - foreach (var md in Directory.EnumerateFiles(baseDir, "*.md", SearchOption.AllDirectories).Where(IsSource)) - builder.Configuration.AddAgentMarkdown(md, optional: false, reloadOnChange: true); - } - - return builder; - } - -#if WEB - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) - where TBuilder : IHostApplicationBuilder - { - builder.Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - return builder; - } - - public static WebApplication MapDefaultEndpoints(this WebApplication app) - { - // Adding health checks endpoints to applications in non-development environments has security implications. - // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. - if (app.Environment.IsDevelopment()) - { - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks(HealthEndpointPath); - - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }); - } - - return app; - } -#endif -} diff --git a/src/Tests/ConfigurableAgentTests.cs b/src/Tests/ConfigurableAgentTests.cs deleted file mode 100644 index 89116a0..0000000 --- a/src/Tests/ConfigurableAgentTests.cs +++ /dev/null @@ -1,890 +0,0 @@ -using Devlooped.Extensions.AI; -using Devlooped.Extensions.AI.Grok; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Moq; - -namespace Devlooped.Agents.AI; - -public class ConfigurableAgentTests(ITestOutputHelper output) -{ - [Fact] - public void CanConfigureAgent() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddInMemoryCollection(new Dictionary - { - ["ai:clients:chat:modelid"] = "gpt-4.1-nano", - ["ai:clients:chat:apikey"] = "sk-asdfasdf", - ["ai:agents:bot:client"] = "chat", - ["ai:agents:bot:name"] = "chat", - ["ai:agents:bot:description"] = "Helpful chat agent", - ["ai:agents:bot:instructions"] = "You are a helpful chat agent.", - ["ai:agents:bot:options:temperature"] = "0.5", - ["ai:agents:bot:emoji"] = "🤖", - }); - - builder.AddAIAgents(); - - var app = builder.Build(); - - var agent = app.Services.GetRequiredKeyedService("chat"); - - Assert.Equal("chat", agent.Name); - Assert.Equal("chat", agent.DisplayName); - Assert.Equal("Helpful chat agent", agent.Description); - - var additional = Assert.IsType(agent, exactMatch: false); - Assert.Equal("🤖", additional.AdditionalProperties?["emoji"]?.ToString()); - Assert.Equal("🤖", agent.Emoji); - } - - [Fact] - public void CanGetFromAlternativeKey() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddInMemoryCollection(new Dictionary - { - ["ai:clients:Chat:modelid"] = "gpt-4.1-nano", - ["ai:clients:Chat:apikey"] = "sk-asdfasdf", - // NOTE: mismatched case in client id - ["ai:agents:bot:client"] = "chat", - }); - - builder.AddAIAgents(); - - var app = builder.Build(); - - var agent = app.Services.GetRequiredKeyedService(new ServiceKey("Bot")); - - Assert.Equal("bot", agent.Name); - Assert.Same(agent, app.Services.GetIAAgent("Bot")); - } - - [Fact] - public void CanGetSectionAndIdFromMetadata() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddInMemoryCollection(new Dictionary - { - ["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("bot"); - var metadata = agent.GetService(); - - Assert.NotNull(metadata); - Assert.Equal("bot", metadata.Name); - Assert.Equal("ai:agents:bot", metadata.ConfigurationSection); - } - - [Fact] - public void DedentsDescriptionAndInstructions() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddInMemoryCollection(new Dictionary - { - ["ai:clients:chat:modelid"] = "gpt-4.1-nano", - ["ai:clients:chat:apikey"] = "sk-asdfasdf", - ["ai:agents:bot:client"] = "chat", - ["ai:agents:bot:name"] = "chat", - ["ai:agents:bot:description"] = - """ - - - Line 1 - Line 2 - Line 3 - - """, - ["ai:agents:bot:instructions"] = - """ - Agent Instructions: - - Step 1 - - Step 2 - - Step 3 - """, - ["ai:agents:bot:options:temperature"] = "0.5", - }); - - builder.AddAIAgents(); - - var app = builder.Build(); - - var agent = app.Services.GetRequiredKeyedService("chat"); - - Assert.Equal( - """ - Line 1 - Line 2 - Line 3 - """, agent.Description); - - Assert.Equal( - """ - Agent Instructions: - - Step 1 - - Step 2 - - Step 3 - """, agent.GetService()?.Instructions); - } - - [Fact] - public void CanReloadConfiguration() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddInMemoryCollection(new Dictionary - { - ["ai:clients:openai:modelid"] = "gpt-4.1-nano", - ["ai:clients:openai:apikey"] = "sk-asdfasdf", - ["ai:clients:grok:modelid"] = "grok-4", - ["ai:clients:grok:apikey"] = "xai-asdfasdf", - ["ai:clients:grok:endpoint"] = "https://api.x.ai", - ["ai:agents:bot:client"] = "openai", - ["ai:agents:bot:description"] = "Helpful chat agent", - ["ai:agents:bot:instructions"] = "You are a helpful agent.", - }); - - builder.AddAIAgents(); - - var app = builder.Build(); - - var agent = app.Services.GetRequiredKeyedService("bot"); - - Assert.Equal("Helpful chat agent", agent.Description); - Assert.Equal("You are a helpful agent.", agent.GetService()?.Instructions); - Assert.Equal("openai", agent.GetService()?.ProviderName); - - // Change the configuration to point to a different client - var configuration = (IConfigurationRoot)app.Services.GetRequiredService(); - configuration["ai:agents:bot:client"] = "grok"; - configuration["ai:agents:bot:description"] = "Very helpful chat agent"; - configuration["ai:agents:bot:instructions"] = "You are a very helpful chat agent."; - - // NOTE: the in-memory provider does not support reload on change, so we must trigger it manually. - configuration.Reload(); - - Assert.Equal("Very helpful chat agent", agent.Description); - Assert.Equal("You are a very helpful chat agent.", agent.GetService()?.Instructions); - Assert.Equal("xai", agent.GetService()?.ProviderName); - } - - [Fact] - public void AssignsContextProviderFromService() - { - var builder = new HostApplicationBuilder(); - var context = Mock.Of(); - - builder.Services.AddSingleton( - Mock.Of(x - => x.CreateProvider(It.IsAny()) == context)); - - builder.Configuration.AddInMemoryCollection(new Dictionary - { - ["ai:clients:chat:modelid"] = "gpt-4.1-nano", - ["ai:clients:chat:apikey"] = "sk-asdfasdf", - ["ai:agents:bot:client"] = "chat", - ["ai:agents:bot:options:temperature"] = "0.5", - }); - - builder.AddAIAgents(); - - var app = builder.Build(); - var agent = app.Services.GetRequiredKeyedService("bot"); - var options = agent.GetService(); - - Assert.NotNull(options?.AIContextProviderFactory); - Assert.Same(context, options?.AIContextProviderFactory?.Invoke(new())); - } - - [Fact] - public void AssignsMessageStoreFactoryFromKeyedService() - { - var builder = new HostApplicationBuilder(); - var context = Mock.Of(); - - builder.Services.AddKeyedSingleton("bot", - Mock.Of(x - => x.CreateStore(It.IsAny()) == context)); - - builder.Configuration.AddInMemoryCollection(new Dictionary - { - ["ai:clients:chat:modelid"] = "gpt-4.1-nano", - ["ai:clients:chat:apikey"] = "sk-asdfasdf", - ["ai:agents:bot:client"] = "chat", - ["ai:agents:bot:options:temperature"] = "0.5", - }); - - builder.AddAIAgents(); - - var app = builder.Build(); - var agent = app.Services.GetRequiredKeyedService("bot"); - var options = agent.GetService(); - - Assert.NotNull(options?.ChatMessageStoreFactory); - Assert.Same(context, options?.ChatMessageStoreFactory?.Invoke(new())); - } - - [Fact] - public void AssignsMessageStoreFactoryFromService() - { - var builder = new HostApplicationBuilder(); - var context = Mock.Of(); - - builder.Services.AddSingleton( - Mock.Of(x - => x.CreateStore(It.IsAny()) == context)); - - builder.Configuration.AddInMemoryCollection(new Dictionary - { - ["ai:clients:chat:modelid"] = "gpt-4.1-nano", - ["ai:clients:chat:apikey"] = "sk-asdfasdf", - ["ai:agents:bot:client"] = "chat", - ["ai:agents:bot:options:temperature"] = "0.5", - }); - - builder.AddAIAgents(); - - var app = builder.Build(); - var agent = app.Services.GetRequiredKeyedService("bot"); - var options = agent.GetService(); - - Assert.NotNull(options?.ChatMessageStoreFactory); - Assert.Same(context, options?.ChatMessageStoreFactory?.Invoke(new())); - } - - [Fact] - public void CanSetOpenAIReasoningAndVerbosity() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddInMemoryCollection(new Dictionary - { - ["ai:clients:openai:modelid"] = "gpt-4.1", - ["ai:clients:openai:apikey"] = "sk-asdfasdf", - ["ai:agents:bot:client"] = "openai", - ["ai:agents:bot:options:reasoningeffort"] = "minimal", - ["ai:agents:bot:options:verbosity"] = "low", - }); - - builder.AddAIAgents(); - var app = builder.Build(); - var agent = app.Services.GetRequiredKeyedService("bot"); - var options = agent.GetService(); - - Assert.Equal(Verbosity.Low, options?.ChatOptions?.Verbosity); - Assert.Equal(ReasoningEffort.Minimal, options?.ChatOptions?.ReasoningEffort); - } - - //[Fact] - //public void CanSetGrokOptions() - //{ - // var builder = new HostApplicationBuilder(); - - // builder.Configuration.AddInMemoryCollection(new Dictionary - // { - // ["ai:clients:grok:modelid"] = "grok-4", - // ["ai:clients:grok:apikey"] = "xai-asdfasdf", - // ["ai:clients:grok:endpoint"] = "https://api.x.ai", - // ["ai:agents:bot:client"] = "grok", - // ["ai:agents:bot:options:reasoningeffort"] = "low", - // ["ai:agents:bot:options:search"] = "auto", - // }); - - // builder.AddAIAgents(); - // var app = builder.Build(); - // var agent = app.Services.GetRequiredKeyedService("bot"); - // var options = agent.GetService(); - - // var grok = Assert.IsType(options?.ChatOptions); - - // Assert.Equal(ReasoningEffort.Low, grok.ReasoningEffort); - // Assert.Equal(GrokSearch.Auto, grok.Search); - - // var configuration = (IConfigurationRoot)app.Services.GetRequiredService(); - // configuration["ai:agents:bot:options:reasoningeffort"] = "high"; - // configuration["ai:agents:bot:options:search"] = "off"; - // // NOTE: the in-memory provider does not support reload on change, so we must trigger it manually. - // configuration.Reload(); - - // options = agent.GetService(); - // grok = Assert.IsType(options?.ChatOptions); - - // Assert.Equal(ReasoningEffort.High, grok.ReasoningEffort); - // Assert.Equal(GrokSearch.Off, grok.Search); - //} - - [Fact] - public void UseContextProviderFactoryFromKeyedService() - { - var builder = new HostApplicationBuilder(); - var context = Mock.Of(); - - builder.Services.AddKeyedSingleton("bot", - Mock.Of(x - => x.CreateProvider(It.IsAny()) == context)); - - builder.Configuration.AddInMemoryCollection(new Dictionary - { - ["ai:clients:chat:modelid"] = "gpt-4.1-nano", - ["ai:clients:chat:apikey"] = "sk-asdfasdf", - ["ai:agents:bot:client"] = "chat", - ["ai:agents:bot:options:temperature"] = "0.5", - }); - - builder.AddAIAgents(); - - var app = builder.Build(); - var agent = app.Services.GetRequiredKeyedService("bot"); - var options = agent.GetService(); - - Assert.NotNull(options?.AIContextProviderFactory); - Assert.Same(context, options?.AIContextProviderFactory?.Invoke(new ChatClientAgentOptions.AIContextProviderFactoryContext())); - } - - [Fact] - public async Task UseContextProviderFromKeyedServiceAsync() - { - var builder = new HostApplicationBuilder(); - var context = new AIContext(); - - var provider = new Mock(); - provider - .Setup(x => x.InvokingAsync(It.IsAny(), default(CancellationToken))) - .ReturnsAsync(context); - - builder.Services.AddKeyedSingleton("chat", provider.Object); - - builder.Configuration.AddToml( - """" - [ai.clients.openai] - modelid = "gpt-4.1" - apikey = "sk-asdf" - - [ai.agents.chat] - description = "Chat agent." - client = "openai" - """"); - - builder.AddAIAgents(); - - var app = builder.Build(); - var agent = app.Services.GetRequiredKeyedService("chat"); - var options = agent.GetService(); - - Assert.NotNull(options?.AIContextProviderFactory); - - var actualProvider = options?.AIContextProviderFactory?.Invoke(new()); - - Assert.NotNull(actualProvider); - - Assert.Same(context, await actualProvider.InvokingAsync(new([]), default)); - } - - [Fact] - public void UseAndContextProviderFactoryIncompatible() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddToml( - """" - [ai.clients.openai] - modelid = "gpt-4.1" - apikey = "sk-asdf" - - [ai.agents.chat] - description = "Chat agent." - client = "openai" - use = ["voseo"] - - [ai.context.voseo] - instructions = 'Default to using spanish language, using argentinean "voseo" in your responses' - """"); - - builder.AddAIAgents(configureOptions: (name, options) - => options.AIContextProviderFactory = context => Mock.Of()); - - var app = builder.Build(); - - var exception = Assert.ThrowsAny(() => app.Services.GetRequiredKeyedService("chat")); - - Assert.Contains("ai:agents:chat:use", exception.Message); - } - - [Fact] - public async Task UseAndContextProviderCompositeAsync() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddToml( - """" - [ai.clients.openai] - modelid = "gpt-4.1" - apikey = "sk-asdf" - - [ai.agents.chat] - description = "Chat agent." - client = "openai" - use = ["voseo"] - - [ai.context.voseo] - instructions = """\ - Default to using spanish language, using argentinean "voseo" in your responses \ - (unless the user explicitly talks in a different language). \ - This means using "vos" instead of "tú" and conjugating verbs accordingly. \ - Don't use the expression "pa'" instead of "para". Don't mention the word "voseo". - """ - """"); - - var context = new AIContext { Instructions = "foo" }; - - var provider = new Mock(); - provider - .Setup(x => x.InvokingAsync(It.IsAny(), default(CancellationToken))) - .ReturnsAsync(context); - - builder.Services.AddKeyedSingleton("chat", provider.Object); - builder.AddAIAgents(); - - var app = builder.Build(); - var agent = app.Services.GetRequiredKeyedService("chat"); - - var options = agent.GetService(); - Assert.NotNull(options?.AIContextProviderFactory); - - var actualProvider = options?.AIContextProviderFactory?.Invoke(new()); - Assert.NotNull(actualProvider); - - var actualContext = await actualProvider.InvokingAsync(new([]), default); - Assert.Contains("spanish language", actualContext.Instructions); - Assert.Contains("foo", actualContext.Instructions); - } - - [Fact] - public async Task UseAIContextFromKeyedServiceAsync() - { - var builder = new HostApplicationBuilder(); - var voseo = new AIContext { Instructions = "voseo" }; - - builder.Configuration.AddToml( - """" - [ai.clients.openai] - modelid = "gpt-4.1" - apikey = "sk-asdf" - - [ai.agents.chat] - description = "Chat agent." - client = "openai" - use = ["voseo"] - """"); - - builder.Services.AddKeyedSingleton("voseo", voseo); - - builder.AddAIAgents(); - - var app = builder.Build(); - var agent = app.Services.GetRequiredKeyedService("chat"); - var options = agent.GetService(); - - Assert.NotNull(options?.AIContextProviderFactory); - var actualProvider = options?.AIContextProviderFactory?.Invoke(new()); - Assert.NotNull(actualProvider); - - var actualContext = await actualProvider.InvokingAsync(new([]), default); - - Assert.Same(voseo, await actualProvider.InvokingAsync(new([]), default)); - } - - [Fact] - public async Task UseAggregatedAIContextsFromKeyedServiceAsync() - { - var builder = new HostApplicationBuilder(); - var voseo = new AIContext { Instructions = "voseo" }; - var formatting = new AIContext { Instructions = "formatting" }; - - builder.Configuration.AddToml( - """" - [ai.clients.openai] - modelid = "gpt-4.1" - apikey = "sk-asdf" - - [ai.agents.chat] - description = "Chat agent." - client = "openai" - use = ["voseo", "formatting"] - """"); - - builder.Services.AddKeyedSingleton("voseo", voseo); - builder.Services.AddKeyedSingleton("formatting", formatting); - - builder.AddAIAgents(); - - var app = builder.Build(); - var agent = app.Services.GetRequiredKeyedService("chat"); - var options = agent.GetService(); - - Assert.NotNull(options?.AIContextProviderFactory); - var actualProvider = options?.AIContextProviderFactory?.Invoke(new()); - Assert.NotNull(actualProvider); - - var actualContext = await actualProvider.InvokingAsync(new([]), default); - - Assert.StartsWith(voseo.Instructions, actualContext.Instructions); - Assert.EndsWith(formatting.Instructions, actualContext.Instructions); - } - - [Fact] - public async Task UseAIToolFromKeyedServiceAsync() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddToml( - """" - [ai.clients.openai] - modelid = "gpt-4.1" - apikey = "sk-asdf" - - [ai.agents.chat] - description = "Chat agent." - client = "openai" - tools = ["get_date"] - """"); - - AITool tool = AIFunctionFactory.Create(() => DateTimeOffset.Now, "get_date"); - builder.Services.AddKeyedSingleton("get_date", tool); - builder.AddAIAgents(); - - var app = builder.Build(); - var agent = app.Services.GetRequiredKeyedService("chat"); - var options = agent.GetService(); - - Assert.NotNull(options?.AIContextProviderFactory); - var provider = options?.AIContextProviderFactory?.Invoke(new()); - Assert.NotNull(provider); - - var context = await provider.InvokingAsync(new([]), default); - - Assert.NotNull(context.Tools); - Assert.Single(context.Tools); - Assert.Same(tool, context.Tools[0]); - } - - [Fact] - public async Task MissingAIToolFromKeyedServiceThrows() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddToml( - $$""" - [ai.clients.openai] - modelid = "gpt-4.1" - apikey = "sk-asdf" - - [ai.agents.chat] - description = "Chat agent." - client = "openai" - tools = ["get_date"] - """); - - builder.AddAIAgents(); - var app = builder.Build(); - - var exception = Assert.ThrowsAny(() => app.Services.GetRequiredKeyedService("chat")); - - Assert.Contains("get_date", exception.Message); - Assert.Contains("ai:agents:chat", exception.Message); - } - - [Fact] - public async Task UseAIContextFromSection() - { - var builder = new HostApplicationBuilder(); - var voseo = - """ - Default to using spanish language, using argentinean "voseo" in your responses. - """; - - builder.Configuration.AddToml( - $$""" - [ai.clients.openai] - modelid = "gpt-4.1" - apikey = "sk-asdf" - - [ai.agents.chat] - description = "Chat agent." - client = "openai" - use = ["default"] - - [ai.context.default] - instructions = '{{voseo}}' - messages = [ - { system = "You are strictly professional." }, - { user = "Hey you!"}, - { assistant = "Hello there. How can I assist you today?" } - ] - tools = ["get_date"] - """); - - var tool = AIFunctionFactory.Create(() => DateTimeOffset.Now, "get_date"); - builder.Services.AddKeyedSingleton("get_date", tool); - builder.AddAIAgents(); - var app = builder.Build(); - - var agent = app.Services.GetRequiredKeyedService("chat"); - var options = agent.GetService(); - - Assert.NotNull(options?.AIContextProviderFactory); - var provider = options?.AIContextProviderFactory?.Invoke(new()); - Assert.NotNull(provider); - - var context = await provider.InvokingAsync(new([]), default); - - Assert.NotNull(context.Instructions); - Assert.Equal(voseo, context.Instructions); - Assert.Equal(3, context.Messages?.Count); - Assert.Single(context.Messages!, x => x.Role == ChatRole.System && x.Text == "You are strictly professional."); - Assert.Single(context.Messages!, x => x.Role == ChatRole.User && x.Text == "Hey you!"); - Assert.Single(context.Messages!, x => x.Role == ChatRole.Assistant && x.Text == "Hello there. How can I assist you today?"); - Assert.Same(tool, context.Tools?.First()); - } - - [Fact] - public async Task UseAIContextFromProvider() - { - var builder = new HostApplicationBuilder(); - var voseo = - """ - Default to using spanish language, using argentinean "voseo" in your responses. - """; - - builder.Configuration.AddToml( - $$""" - [ai.clients.openai] - modelid = "gpt-4.1" - apikey = "sk-asdf" - - [ai.agents.chat] - description = "Chat agent." - client = "openai" - use = ["default"] - """); - - var tool = AIFunctionFactory.Create(() => DateTimeOffset.Now, "get_date"); - builder.Services.AddKeyedSingleton("default", Mock.Of(x - => x.InvokingAsync(It.IsAny(), default) == ValueTask.FromResult(new AIContext - { - Instructions = voseo, - Tools = new[] { tool } - }))); - - builder.AddAIAgents(); - var app = builder.Build(); - - var agent = app.Services.GetRequiredKeyedService("chat"); - var options = agent.GetService(); - - Assert.NotNull(options?.AIContextProviderFactory); - var provider = options?.AIContextProviderFactory?.Invoke(new()); - Assert.NotNull(provider); - - var context = await provider.InvokingAsync(new([]), default); - - Assert.NotNull(context.Instructions); - Assert.Equal(voseo, context.Instructions); - Assert.Same(tool, context.Tools?.First()); - } - - [Fact] - public async Task CombineAIContextFromStaticDinamicAndSection() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddToml( - $$""" - [ai.clients.openai] - modelid = "gpt-4.1" - apikey = "sk-asdf" - - [ai.agents.chat] - description = "Chat agent." - client = "openai" - use = ["default", "static", "dynamic"] - - [ai.context.default] - instructions = 'foo' - messages = [ - { system = "You are strictly professional." }, - { user = "Hey you!"}, - { assistant = "Hello there. How can I assist you today?" } - ] - tools = ["get_date"] - """); - - var tool = AIFunctionFactory.Create(() => DateTimeOffset.Now, "get_date"); - builder.Services.AddKeyedSingleton("get_date", tool); - - builder.Services.AddKeyedSingleton("static", new AIContext - { - Instructions = "bar", - Tools = new AITool[] { AIFunctionFactory.Create(() => "bar", "get_bar") } - }); - - AITool[] getbaz = [AIFunctionFactory.Create(() => "baz", "get_baz")]; - - builder.Services.AddKeyedSingleton("dynamic", Mock.Of(x - => x.InvokingAsync(It.IsAny(), default) == ValueTask.FromResult(new AIContext - { - Instructions = "baz", - Tools = getbaz - }))); - - builder.AddAIAgents(); - var app = builder.Build(); - - var agent = app.Services.GetRequiredKeyedService("chat"); - var options = agent.GetService(); - - Assert.NotNull(options?.AIContextProviderFactory); - var provider = options?.AIContextProviderFactory?.Invoke(new()); - Assert.NotNull(provider); - - var context = await provider.InvokingAsync(new([]), default); - - Assert.NotNull(context.Instructions); - Assert.Contains("foo", context.Instructions); - Assert.Contains("bar", context.Instructions); - Assert.Contains("baz", context.Instructions); - - Assert.Equal(3, context.Messages?.Count); - Assert.Single(context.Messages!, x => x.Role == ChatRole.System && x.Text == "You are strictly professional."); - Assert.Single(context.Messages!, x => x.Role == ChatRole.User && x.Text == "Hey you!"); - Assert.Single(context.Messages!, x => x.Role == ChatRole.Assistant && x.Text == "Hello there. How can I assist you today?"); - - Assert.NotNull(context.Tools); - Assert.Contains(tool, context.Tools!); - Assert.Contains(context.Tools, x => x.Name == "get_bar"); - Assert.Contains(context.Tools, x => x.Name == "get_baz"); - } - - [Fact] - public async Task MissingToolAIContextFromSectionThrows() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddToml( - $$""" - [ai.clients.openai] - modelid = "gpt-4.1" - apikey = "sk-asdf" - - [ai.agents.chat] - description = "Chat agent." - client = "openai" - use = ["default"] - - [ai.context.default] - tools = ["get_date"] - """); - - builder.AddAIAgents(); - var app = builder.Build(); - - var exception = Assert.ThrowsAny(() => app.Services.GetRequiredKeyedService("chat")); - - Assert.Contains("get_date", exception.Message); - Assert.Contains("ai:context:default:tools", exception.Message); - Assert.Contains("ai:agents:chat", exception.Message); - } - - [Fact] - public async Task UnknownUseThrows() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddToml( - $$""" - [ai.clients.openai] - modelid = "gpt-4.1" - apikey = "sk-asdf" - - [ai.agents.chat] - description = "Chat agent." - client = "openai" - use = ["foo"] - """); - - builder.AddAIAgents(); - var app = builder.Build(); - - var exception = Assert.ThrowsAny(() => app.Services.GetRequiredKeyedService("chat")); - - Assert.Contains("foo", exception.Message); - } - - [Fact] - public async Task OverrideModelFromAgentChatOptions() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddToml( - $$""" - [ai.clients.openai] - modelid = "gpt-4.1" - apikey = "sk-asdf" - - [ai.agents.chat] - description = "Chat" - client = "openai" - options = { modelid = "gpt-5" } - """); - - builder.AddAIAgents(); - var app = builder.Build(); - - var agent = app.Services.GetRequiredKeyedService("chat"); - var options = agent.GetService(); - - Assert.Equal("gpt-5", options?.ChatOptions?.ModelId); - } - - [Fact] - public async Task OverrideModelFromAgentModel() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddToml( - $$""" - [ai.clients.openai] - modelid = "gpt-4.1" - apikey = "sk-asdf" - - [ai.agents.chat] - description = "Chat" - client = "openai" - model = "gpt-5" - """); - - builder.AddAIAgents(); - var app = builder.Build(); - - var agent = app.Services.GetRequiredKeyedService("chat"); - var options = agent.GetService(); - - Assert.Equal("gpt-5", options?.ChatOptions?.ModelId); - } -} - diff --git a/src/Tests/Misc.cs b/src/Tests/Misc.cs deleted file mode 100644 index bf0fad3..0000000 --- a/src/Tests/Misc.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Text; -using Devlooped.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Configuration; - -namespace Devlooped; - -public class Misc -{ - [Fact] - public void AddMarkdown() - { - var markdown = - """ - --- - id: ai.agents.tests - name: TestAgent - description: Test agent - options: - temperature: 0.7 - use: ["foo", "bar"] - --- - Hello world - """; - - var configuration = new ConfigurationBuilder() - .AddAgentMarkdown(new MemoryStream(Encoding.UTF8.GetBytes(markdown))) - .Build(); - - Assert.Equal("TestAgent", configuration["ai:agents:tests:name"]); - Assert.Equal("Hello world", configuration["ai:agents:tests:instructions"]); - - var agent = configuration.GetSection("ai:agents:tests").Get(); - - Assert.NotNull(agent); - Assert.Equal("TestAgent", agent.Name); - Assert.Equal("Test agent", agent.Description); - Assert.Equal(0.7f, agent.Options?.Temperature); - Assert.Equal(["foo", "bar"], agent.Use); - Assert.Equal("Hello world", agent.Instructions); - } - - record AgentConfig(string Name, string Description, string Instructions, ChatOptions? Options, List Use); -} diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index cefafa6..0057abe 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -31,7 +31,6 @@ -