diff --git a/.github/agents/notes.agent.md b/.github/agents/notes.agent.md new file mode 100644 index 0000000..9945ecb --- /dev/null +++ b/.github/agents/notes.agent.md @@ -0,0 +1,14 @@ +--- +id: ai.agents.notes +description: 'Takes notes' +model: Grok Code Fast 1 (copilot) +client: grok +options: + modelid: grok-code-fast-1 +tools: ['edit'] +use: ['tone'] +--- +# Notes Agent +This agent is designed to take notes based on user input. It can capture important information, summarize discussions, and organize notes for easy retrieval later. The Notes Agent can be particularly useful in meetings, brainstorming sessions, or any scenario where capturing key points is essential. + +It saves these notes in JSON-LD format to the file `notes.json` alongside this agent, ensuring that the notes are structured and easily accessible for future reference. \ No newline at end of file diff --git a/.github/agents/notes.json b/.github/agents/notes.json new file mode 100644 index 0000000..dcee378 --- /dev/null +++ b/.github/agents/notes.json @@ -0,0 +1,28 @@ +[ + { + "@context": "https://schema.org", + "@type": "NoteDigitalDocument", + "identifier": "2025-10-29T00:00:00Z", + "dateCreated": "2025-10-29", + "inLanguage": "es", + "text": "El usuario necesita desplegar una aplicación Expo.", + "about": [ + "Expo", + "deploy" + ] + }, + { + "@context": "https://schema.org", + "@type": "Note", + "dateCreated": "2025-10-29T12:00:00Z", + "inLanguage": "es", + "textOriginal": "recordar que maniana llevamos piedras", + "text": "Recordar que mañana llevamos piedras", + "dueDate": "2025-10-30", + "tags": ["recordatorio", "piedras"], + "source": { + "agentFile": ".github/agents/notes.agent.md", + "savedBy": "notes-agent" + } + } +] \ No newline at end of file diff --git a/assets/img/agent-model.png b/assets/img/agent-model.png new file mode 100644 index 0000000..4efa966 Binary files /dev/null and b/assets/img/agent-model.png differ diff --git a/readme.md b/readme.md index a96434e..83c8d41 100644 --- a/readme.md +++ b/readme.md @@ -133,29 +133,45 @@ This can be used by leveraging [Tomlyn.Extensions.Configuration](https://www.nug > avoiding unnecessary tokens being used for indentation while allowing flexible > formatting in the config file. -For longer instructions, markdown format plus YAML front-matter can be used for better readability: +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 -options: - modelid: grok-4-fast +model: grok-4-fast --- You organize and keep notes for the user. # Some header More content +``` -## Another header -... +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 ``` -Use the provided `AddInstructionsFile` extension method to load instructions from files as follows: +![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.AddInstructionsFile("notes.md", optional: false, reloadOnChange: true); +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 @@ -227,15 +243,16 @@ services.AddKeyedSingleton("get_date", AIFunctionFactory.Create(() => DateTimeOf This tool will be automatically wired into any agent that uses the `timezone` context above. -As a shortcut when you want to just pull in a tool from DI into an agent's context without having to define an entire -section just for that, you can specify the tool name directly in the `use` array: +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", "get_date"] +use = ["tone"] +tools = ["get_date"] ``` This enables a flexible and convenient mix of static and dynamic context for agents, all driven diff --git a/sample/Server/ai.toml b/sample/Server/ai.toml index 2c3ad11..5cbef20 100644 --- a/sample/Server/ai.toml +++ b/sample/Server/ai.toml @@ -11,23 +11,16 @@ 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", "get_date", "create_order", "cancel_order"] +use = ["tone"] +tools = ["get_date", "create_order", "cancel_order"] -[ai.agents.orders.options] -modelid = "gpt-4o-mini" -# additional properties could be added here - -[ai.agents.notes] -description = "Help users create, manage, and retrieve notes effectively." -instructions = """ - You are an AI agent that assists users in creating, managing, and retrieving notes. - Your primary goals are to understand user requests related to notes, provide clear and concise responses, and utilize tools to organize and access note data efficiently. - """ -client = "grok" -use = ["tone", "save_notes", "get_date"] [ai.context.tone] instructions = """\ diff --git a/sample/Server/notes.md b/sample/Server/notes.agent.md similarity index 60% rename from sample/Server/notes.md rename to sample/Server/notes.agent.md index a26a711..fe7ac9c 100644 --- a/sample/Server/notes.md +++ b/sample/Server/notes.agent.md @@ -1,8 +1,9 @@ --- id: ai.agents.notes description: Provides free-form memory -client: Grok -options: - modelid: grok-4-fast +client: grok +model: grok-4-fast +use: ["tone"] +tools: ["save_notes", "get_date"] --- You organize and keep notes for the user, using JSON-LD \ No newline at end of file diff --git a/sample/ServiceDefaults.cs b/sample/ServiceDefaults.cs index a1d44e2..50328a6 100644 --- a/sample/ServiceDefaults.cs +++ b/sample/ServiceDefaults.cs @@ -93,7 +93,7 @@ public static TBuilder ConfigureReload(this TBuilder builder) builder.Configuration.AddJsonFile(json, optional: false, reloadOnChange: true); foreach (var md in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.md", SearchOption.AllDirectories)) - builder.Configuration.AddInstructionsFile(md, optional: false, reloadOnChange: true); + builder.Configuration.AddAgentMarkdown(md, optional: false, reloadOnChange: true); } else { @@ -111,7 +111,7 @@ public static TBuilder ConfigureReload(this TBuilder builder) builder.Configuration.AddJsonFile(json, optional: false, reloadOnChange: true); foreach (var md in Directory.EnumerateFiles(baseDir, "*.md", SearchOption.AllDirectories).Where(IsSource)) - builder.Configuration.AddInstructionsFile(md, optional: false, reloadOnChange: true); + builder.Configuration.AddAgentMarkdown(md, optional: false, reloadOnChange: true); } return builder; diff --git a/src/Agents/ConfigurableInstructionsExtensions.cs b/src/Agents/AgentMarkdownExtensions.cs similarity index 91% rename from src/Agents/ConfigurableInstructionsExtensions.cs rename to src/Agents/AgentMarkdownExtensions.cs index ae1e925..6780c7a 100644 --- a/src/Agents/ConfigurableInstructionsExtensions.cs +++ b/src/Agents/AgentMarkdownExtensions.cs @@ -6,12 +6,12 @@ namespace Devlooped.Agents.AI; [EditorBrowsable(EditorBrowsableState.Never)] -public static class ConfigurableInstructionsExtensions +public static class AgentMarkdownExtensions { /// /// Adds an instructions markdown file with optional YAML front-matter to the configuration sources. /// - public static IConfigurationBuilder AddInstructionsFile(this IConfigurationBuilder builder, string path, bool optional = false, bool reloadOnChange = false) + public static IConfigurationBuilder AddAgentMarkdown(this IConfigurationBuilder builder, string path, bool optional = false, bool reloadOnChange = false) => builder.Add(source => { source.Path = path; @@ -23,7 +23,7 @@ public static IConfigurationBuilder AddInstructionsFile(this IConfigurationBuild /// /// Adds an instructions markdown stream with optional YAML front-matter to the configuration sources. /// - public static IConfigurationBuilder AddInstructionsStream(this IConfigurationBuilder builder, Stream stream) + public static IConfigurationBuilder AddAgentMarkdown(this IConfigurationBuilder builder, Stream stream) => Throw.IfNull(builder).Add((InstructionsStreamConfigurationSource source) => source.Stream = stream); static class InstructionsParser diff --git a/src/Agents/ConfigurableAIAgent.cs b/src/Agents/ConfigurableAIAgent.cs index f2a231d..9f3a92e 100644 --- a/src/Agents/ConfigurableAIAgent.cs +++ b/src/Agents/ConfigurableAIAgent.cs @@ -105,6 +105,8 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum if (chat is not null) options.ChatOptions = chat; + else if (options.Model is not null) + (options.ChatOptions ??= new()).ModelId = options.Model; configure?.Invoke(name, options); @@ -127,10 +129,10 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum options.AIContextProviderFactory = _ => contextProvider; } - else if (options.Use?.Count > 0) + else if (options.Use?.Count > 0 || options.Tools?.Count > 0) { var contexts = new List(); - foreach (var use in options.Use) + foreach (var use in options.Use ?? []) { var context = services.GetKeyedService(use); if (context is not null) @@ -139,13 +141,6 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum continue; } - var function = services.GetKeyedService(use) ?? services.GetKeyedService(use); - if (function is not null) - { - contexts.Add(new AIContext { Tools = [function] }); - continue; - } - if (configuration.GetSection("ai:context:" + use) is { } ctxSection && ctxSection.Get() is { } ctxConfig) { @@ -161,7 +156,7 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum { var tool = services.GetKeyedService(toolName) ?? services.GetKeyedService(toolName) ?? - throw new InvalidOperationException($"Specified tool '{toolName}' for AI context '{ctxSection.Path}:tools' is not registered, and is required by agent section '{configSection.Path}'."); + throw new InvalidOperationException($"Specified tool '{toolName}' for AI context '{ctxSection.Path}:tools' is not registered as a keyed {nameof(AITool)} or {nameof(AIFunction)}, and is required by agent section '{configSection.Path}'."); configured.Tools ??= []; configured.Tools.Add(tool); @@ -172,7 +167,16 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum continue; } - throw new InvalidOperationException($"Specified AI context '{use}' for agent '{name}' is not registered as either {nameof(AIContent)}, {nameof(AITool)} or configuration section 'ai:context:{use}'."); + throw new InvalidOperationException($"Specified AI context '{use}' for agent '{name}' is not registered as either {nameof(AIContent)} or configuration section 'ai:context:{use}'."); + } + + foreach (var toolName in options.Tools ?? []) + { + var tool = services.GetKeyedService(toolName) ?? + services.GetKeyedService(toolName) ?? + throw new InvalidOperationException($"Specified tool '{toolName}' for agent '{section}' is not registered as a keyed {nameof(AITool)} or {nameof(AIFunction)}."); + + contexts.Add(new AIContext { Tools = [tool] }); } options.AIContextProviderFactory = _ => new CompositeAIContextProvider(contexts); @@ -215,7 +219,9 @@ void OnReload(object? state) internal class AgentClientOptions : ChatClientAgentOptions { public string? Client { get; set; } + public string? Model { get; set; } public IList? Use { get; set; } + public IList? Tools { get; set; } } } diff --git a/src/Tests/ConfigurableAgentTests.cs b/src/Tests/ConfigurableAgentTests.cs index 97956d3..42e62e4 100644 --- a/src/Tests/ConfigurableAgentTests.cs +++ b/src/Tests/ConfigurableAgentTests.cs @@ -539,7 +539,7 @@ public async Task UseAIToolFromKeyedServiceAsync() [ai.agents.chat] description = "Chat agent." client = "openai" - use = ["get_date"] + tools = ["get_date"] """"); AITool tool = AIFunctionFactory.Create(() => DateTimeOffset.Now, "get_date"); @@ -561,6 +561,32 @@ public async Task UseAIToolFromKeyedServiceAsync() 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() { @@ -668,5 +694,57 @@ public async Task UnknownUseThrows() 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 index 7ace0ca..f048a13 100644 --- a/src/Tests/Misc.cs +++ b/src/Tests/Misc.cs @@ -26,7 +26,7 @@ Hello world """; var configuration = new ConfigurationBuilder() - .AddInstructionsStream(new MemoryStream(Encoding.UTF8.GetBytes(markdown))) + .AddAgentMarkdown(new MemoryStream(Encoding.UTF8.GetBytes(markdown))) .Build(); Assert.Equal("TestAgent", configuration["ai:agents:tests:name"]);