From 1bff22b6de1782fae69fd92ce64d83ac7e43d809 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Wed, 29 Oct 2025 02:15:59 -0300 Subject: [PATCH] Add support for instructions in markdown files with YAML front-matter If instructions are long and the primary content that defines an agent, using TOML is still an inconvenience. For longer text, markdown is king. We add support for markdown instructions by just relying on YAML front-matter to provide the section data to go along with it. Example: ``` --- id: ai.agents.notes description: Provides free-form memory client: Grok options: modelid: grok-4-fast --- You organize and keep notes for the user ``` --- .netconfig | 3 + readme.md | 28 ++++ sample/Client/Client.csproj | 2 + sample/Client/Program.cs | 5 +- sample/Client/appsettings.json | 7 + sample/Directory.Build.targets | 2 +- sample/Server/appsettings.Development.json | 4 +- sample/Server/appsettings.json | 16 +-- sample/Server/notes.md | 8 ++ sample/ServiceDefaults.cs | 20 ++- src/Agents/Agents.csproj | 6 + src/Agents/ConfigurableAIAgent.cs | 1 - .../ConfigurableInstructionsExtensions.cs | 86 +++++++++++ src/Agents/Extensions/.editorconfig | 2 + src/Agents/Extensions/Resources.cs | 16 +++ .../YamlConfigurationStreamParser.cs | 133 ++++++++++++++++++ src/Extensions/ConfigurableChatClient.cs | 3 +- src/Tests/ConfigurableAgentTests.cs | 2 - src/Tests/Content/ai.md | 6 + src/Tests/Misc.cs | 46 ++++++ src/Tests/test.toml | 35 +++++ 21 files changed, 404 insertions(+), 27 deletions(-) create mode 100644 sample/Server/notes.md create mode 100644 src/Agents/ConfigurableInstructionsExtensions.cs create mode 100644 src/Agents/Extensions/.editorconfig create mode 100644 src/Agents/Extensions/Resources.cs create mode 100644 src/Agents/Extensions/YamlConfigurationStreamParser.cs create mode 100644 src/Tests/Content/ai.md create mode 100644 src/Tests/Misc.cs create mode 100644 src/Tests/test.toml diff --git a/.netconfig b/.netconfig index 6dd32a8..161af0c 100644 --- a/.netconfig +++ b/.netconfig @@ -164,3 +164,6 @@ sha = c0a15b3c5e42a6f5e73b8e43ad3a335d7d6f3787 etag = fe8de7929a8ecdb631911233ae3c6bad034b26b9802e62c3521918207f6d4068 weak +[file "src/Agents/Extensions/YamlConfigurationStreamParser.cs"] + url = https://github.com/andrewlock/NetEscapades.Configuration/blob/master/src/NetEscapades.Configuration.Yaml/YamlConfigurationStreamParser.cs + weak diff --git a/readme.md b/readme.md index ef656c3..a96434e 100644 --- a/readme.md +++ b/readme.md @@ -133,6 +133,34 @@ 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: + +```yaml +--- +id: ai.agents.notes +description: Provides free-form memory +client: grok +options: + modelid: grok-4-fast +--- +You organize and keep notes for the user. +# Some header +More content + +## Another header +... +``` + +Use the provided `AddInstructionsFile` extension method to load instructions from files as follows: + +```csharp +var host = new HostApplicationBuilder(args); +host.Configuration.AddInstructionsFile("notes.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 diff --git a/sample/Client/Client.csproj b/sample/Client/Client.csproj index 53ca217..d882d4f 100644 --- a/sample/Client/Client.csproj +++ b/sample/Client/Client.csproj @@ -8,6 +8,7 @@ + @@ -22,6 +23,7 @@ + diff --git a/sample/Client/Program.cs b/sample/Client/Program.cs index ea3c50a..5ae9364 100644 --- a/sample/Client/Program.cs +++ b/sample/Client/Program.cs @@ -1,9 +1,7 @@ using System.Net.Http.Json; -using System.Text.Json.Serialization; using Devlooped.Extensions.AI.OpenAI; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; -using Spectre.Console; var builder = App.CreateBuilder(args); #if DEBUG @@ -11,7 +9,8 @@ #endif builder.AddServiceDefaults(); -builder.Services.AddHttpClient(); +builder.Services.AddHttpClient() + .ConfigureHttpClientDefaults(b => b.AddStandardResilienceHandler()); var app = builder.Build(async (IServiceProvider services, CancellationToken cancellation) => { diff --git a/sample/Client/appsettings.json b/sample/Client/appsettings.json index 1be98d3..6a8dd9e 100644 --- a/sample/Client/appsettings.json +++ b/sample/Client/appsettings.json @@ -7,5 +7,12 @@ "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.targets b/sample/Directory.Build.targets index bee8e0b..ddfc56d 100644 --- a/sample/Directory.Build.targets +++ b/sample/Directory.Build.targets @@ -12,7 +12,7 @@ - + diff --git a/sample/Server/appsettings.Development.json b/sample/Server/appsettings.Development.json index 0c208ae..a284d64 100644 --- a/sample/Server/appsettings.Development.json +++ b/sample/Server/appsettings.Development.json @@ -2,7 +2,9 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting": "Warning", + "System.Net.Http.HttpClient": "Warning" } } } diff --git a/sample/Server/appsettings.json b/sample/Server/appsettings.json index 78b8db9..ccfa333 100644 --- a/sample/Server/appsettings.json +++ b/sample/Server/appsettings.json @@ -7,22 +7,12 @@ }, "AllowedHosts": "*", "AI": { - "Agents": { - "Notes": { - "Name": "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", + "Endpoint": "https://api.x.ai/v1", "ModelId": "grok-4-fast-non-reasoning" } } - } + }, + "OpenTelemetry:ConsoleExporter": true } diff --git a/sample/Server/notes.md b/sample/Server/notes.md new file mode 100644 index 0000000..a26a711 --- /dev/null +++ b/sample/Server/notes.md @@ -0,0 +1,8 @@ +--- +id: ai.agents.notes +description: Provides free-form memory +client: Grok +options: + modelid: grok-4-fast +--- +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 e6a4f4e..a1d44e2 100644 --- a/sample/ServiceDefaults.cs +++ b/sample/ServiceDefaults.cs @@ -1,4 +1,5 @@ -using DotNetEnv.Configuration; +using Devlooped.Agents.AI; +using DotNetEnv.Configuration; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; @@ -6,6 +7,7 @@ using Tomlyn.Extensions.Configuration; + #if WEB using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Diagnostics.HealthChecks; @@ -54,7 +56,10 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) || httpContext.Request.Path.StartsWithSegments(AlivenessEndpointPath))); #endif tracing.AddHttpClientInstrumentation(); - tracing.AddConsoleExporter(); + + // Only add console exporter if explicitly enabled in configuration + if (builder.Configuration.GetValue("OpenTelemetry:ConsoleExporter")) + tracing.AddConsoleExporter(); }) .WithMetrics(metrics => { @@ -83,8 +88,12 @@ public static TBuilder ConfigureReload(this TBuilder builder) { 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.AddInstructionsFile(md, optional: false, reloadOnChange: true); } else { @@ -95,11 +104,14 @@ public static TBuilder ConfigureReload(this TBuilder builder) // 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 toml in Directory.EnumerateFiles(baseDir, "*.toml", SearchOption.AllDirectories).Where(IsSource)) - builder.Configuration.AddTomlFile(toml, optional: false, reloadOnChange: true); + foreach (var md in Directory.EnumerateFiles(baseDir, "*.md", SearchOption.AllDirectories).Where(IsSource)) + builder.Configuration.AddInstructionsFile(md, optional: false, reloadOnChange: true); } return builder; diff --git a/src/Agents/Agents.csproj b/src/Agents/Agents.csproj index f4b074f..c6c516b 100644 --- a/src/Agents/Agents.csproj +++ b/src/Agents/Agents.csproj @@ -28,14 +28,20 @@ + + + + + + diff --git a/src/Agents/ConfigurableAIAgent.cs b/src/Agents/ConfigurableAIAgent.cs index aec3825..f2a231d 100644 --- a/src/Agents/ConfigurableAIAgent.cs +++ b/src/Agents/ConfigurableAIAgent.cs @@ -1,6 +1,5 @@ using System.ComponentModel; using System.Diagnostics; -using System.Runtime.CompilerServices; using System.Text.Json; using Devlooped.Extensions.AI; using Devlooped.Extensions.AI.Grok; diff --git a/src/Agents/ConfigurableInstructionsExtensions.cs b/src/Agents/ConfigurableInstructionsExtensions.cs new file mode 100644 index 0000000..ae1e925 --- /dev/null +++ b/src/Agents/ConfigurableInstructionsExtensions.cs @@ -0,0 +1,86 @@ +using System.ComponentModel; +using System.Text; +using Microsoft.Extensions.Configuration; +using NetEscapades.Configuration.Yaml; + +namespace Devlooped.Agents.AI; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class ConfigurableInstructionsExtensions +{ + /// + /// 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) + => builder.Add(source => + { + source.Path = path; + source.Optional = optional; + source.ReloadOnChange = reloadOnChange; + source.ResolveFileProvider(); + }); + + /// + /// Adds an instructions markdown stream with optional YAML front-matter to the configuration sources. + /// + public static IConfigurationBuilder AddInstructionsStream(this IConfigurationBuilder builder, Stream stream) + => Throw.IfNull(builder).Add((InstructionsStreamConfigurationSource source) => source.Stream = stream); + + static class InstructionsParser + { + public static Dictionary Parse(Stream stream) + { + using var reader = new StreamReader(stream); + var frontMatter = new StringBuilder(); + var line = reader.ReadLine(); + // First line must be the front-matter according to spec. + if (line == "---") + { + while ((line = reader.ReadLine()) != "---" && !reader.EndOfStream) + frontMatter.AppendLine(line); + } + + if (frontMatter.Length > 0 && line != "---") + throw new FormatException("Instructions markdown front-matter is not properly closed with '---'."); + + var instructions = reader.ReadToEnd().Trim(); + var data = new YamlConfigurationStreamParser().Parse(new MemoryStream(Encoding.UTF8.GetBytes(frontMatter.ToString()))); + if (!data.TryGetValue("id", out var value) || Convert.ToString(value) is not { Length: > 1 } id) + throw new FormatException("Instructions markdown file must contain YAML front-matter with an 'id' key that specifies the section identifier."); + + data.Remove("id"); + // id should use the config delimiter rather than dot (which is a typical mistake when coming from TOML) + id = id.Replace(".", ConfigurationPath.KeyDelimiter); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in data) + result[$"{id}{ConfigurationPath.KeyDelimiter}{entry.Key}"] = entry.Value; + + result[$"{id}{ConfigurationPath.KeyDelimiter}instructions"] = instructions; + return result; + } + } + + class InstructionsStreamConfigurationSource : StreamConfigurationSource + { + public override IConfigurationProvider Build(IConfigurationBuilder builder) => new InstructionsStreamConfigurationProvider(this); + } + + class InstructionsStreamConfigurationProvider(InstructionsStreamConfigurationSource source) : StreamConfigurationProvider(source) + { + public override void Load(Stream stream) => Data = InstructionsParser.Parse(stream); + } + + class InstructionsConfigurationSource : FileConfigurationSource + { + public override IConfigurationProvider Build(IConfigurationBuilder builder) + { + EnsureDefaults(builder); + return new InstructionsConfigurationProvider(this); + } + } + + class InstructionsConfigurationProvider(FileConfigurationSource source) : FileConfigurationProvider(source) + { + public override void Load(Stream stream) => Data = InstructionsParser.Parse(stream); + } +} diff --git a/src/Agents/Extensions/.editorconfig b/src/Agents/Extensions/.editorconfig new file mode 100644 index 0000000..f93cc99 --- /dev/null +++ b/src/Agents/Extensions/.editorconfig @@ -0,0 +1,2 @@ +[*.cs] +generated_code = true \ No newline at end of file diff --git a/src/Agents/Extensions/Resources.cs b/src/Agents/Extensions/Resources.cs new file mode 100644 index 0000000..d8f32c8 --- /dev/null +++ b/src/Agents/Extensions/Resources.cs @@ -0,0 +1,16 @@ +// +using System.Globalization; + +namespace NetEscapades.Configuration.Yaml +{ + internal static class Resources + { + /// + /// A duplicate key '{0}' was found. + /// + internal static string FormatError_KeyIsDuplicated(object p0) + { + return string.Format(CultureInfo.CurrentCulture, "A duplicate key '{0}' was found.", p0); + } + } +} \ No newline at end of file diff --git a/src/Agents/Extensions/YamlConfigurationStreamParser.cs b/src/Agents/Extensions/YamlConfigurationStreamParser.cs new file mode 100644 index 0000000..c118de9 --- /dev/null +++ b/src/Agents/Extensions/YamlConfigurationStreamParser.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Resources; +using Microsoft.Extensions.Configuration; +using YamlDotNet.RepresentationModel; + +namespace NetEscapades.Configuration.Yaml +{ + internal class YamlConfigurationStreamParser + { + private readonly IDictionary _data = new SortedDictionary(StringComparer.OrdinalIgnoreCase); + private readonly Stack _context = new Stack(); + private string _currentPath; + + public IDictionary Parse(Stream input) + { + _data.Clear(); + _context.Clear(); + + // https://dotnetfiddle.net/rrR2Bb + var yaml = new YamlStream(); + yaml.Load(new StreamReader(input, detectEncodingFromByteOrderMarks: true)); + + if (yaml.Documents.Any()) + { + var mapping = (YamlMappingNode)yaml.Documents[0].RootNode; + + // The document node is a mapping node + VisitYamlMappingNode(mapping); + } + + return _data; + } + + private void VisitYamlNodePair(KeyValuePair yamlNodePair) + { + var context = ((YamlScalarNode)yamlNodePair.Key).Value; + VisitYamlNode(context, yamlNodePair.Value); + } + + private void VisitYamlNode(string context, YamlNode node) + { + if (node is YamlScalarNode scalarNode) + { + VisitYamlScalarNode(context, scalarNode); + } + if (node is YamlMappingNode mappingNode) + { + VisitYamlMappingNode(context, mappingNode); + } + if (node is YamlSequenceNode sequenceNode) + { + VisitYamlSequenceNode(context, sequenceNode); + } + } + + private void VisitYamlScalarNode(string context, YamlScalarNode yamlValue) + { + //a node with a single 1-1 mapping + EnterContext(context); + var currentKey = _currentPath; + + if (_data.ContainsKey(currentKey)) + { + throw new FormatException(Resources.FormatError_KeyIsDuplicated(currentKey)); + } + + _data[currentKey] = IsNullValue(yamlValue) ? null : yamlValue.Value; + ExitContext(); + } + + private void VisitYamlMappingNode(YamlMappingNode node) + { + foreach (var yamlNodePair in node.Children) + { + VisitYamlNodePair(yamlNodePair); + } + } + + private void VisitYamlMappingNode(string context, YamlMappingNode yamlValue) + { + //a node with an associated sub-document + EnterContext(context); + + VisitYamlMappingNode(yamlValue); + + ExitContext(); + } + + private void VisitYamlSequenceNode(string context, YamlSequenceNode yamlValue) + { + //a node with an associated list + EnterContext(context); + + VisitYamlSequenceNode(yamlValue); + + ExitContext(); + } + + private void VisitYamlSequenceNode(YamlSequenceNode node) + { + for (int i = 0; i < node.Children.Count; i++) + { + VisitYamlNode(i.ToString(), node.Children[i]); + } + } + + private void EnterContext(string context) + { + _context.Push(context); + _currentPath = ConfigurationPath.Combine(_context.Reverse()); + } + + private void ExitContext() + { + _context.Pop(); + _currentPath = ConfigurationPath.Combine(_context.Reverse()); + } + + private bool IsNullValue(YamlScalarNode yamlValue) + { + return yamlValue.Style == YamlDotNet.Core.ScalarStyle.Plain + && ( + yamlValue.Value == "~" + || yamlValue.Value == "null" + || yamlValue.Value == "Null" + || yamlValue.Value == "NULL" + ); + } + } +} \ No newline at end of file diff --git a/src/Extensions/ConfigurableChatClient.cs b/src/Extensions/ConfigurableChatClient.cs index e5beb16..47c9653 100644 --- a/src/Extensions/ConfigurableChatClient.cs +++ b/src/Extensions/ConfigurableChatClient.cs @@ -1,5 +1,4 @@ -using System; -using System.ClientModel.Primitives; +using System.ClientModel.Primitives; using System.ComponentModel; using Azure; using Azure.AI.Inference; diff --git a/src/Tests/ConfigurableAgentTests.cs b/src/Tests/ConfigurableAgentTests.cs index 9c48776..97956d3 100644 --- a/src/Tests/ConfigurableAgentTests.cs +++ b/src/Tests/ConfigurableAgentTests.cs @@ -6,8 +6,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Moq; -using OpenAI.Assistants; -using Tomlyn.Extensions.Configuration; namespace Devlooped.Agents.AI; diff --git a/src/Tests/Content/ai.md b/src/Tests/Content/ai.md new file mode 100644 index 0000000..b34bd22 --- /dev/null +++ b/src/Tests/Content/ai.md @@ -0,0 +1,6 @@ +--- +id: ai.agents.tests +description: Test agent +tools: ["foo", "bar"] +use: ["date"] +--- \ No newline at end of file diff --git a/src/Tests/Misc.cs b/src/Tests/Misc.cs new file mode 100644 index 0000000..7ace0ca --- /dev/null +++ b/src/Tests/Misc.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +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() + .AddInstructionsStream(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/test.toml b/src/Tests/test.toml new file mode 100644 index 0000000..e8b250d --- /dev/null +++ b/src/Tests/test.toml @@ -0,0 +1,35 @@ +[ai.clients.grok] +endpoint = "https://api.x.ai/v1" +modelid = "grok-4-fast-non-reasoning" + +[ai.agents.notes] +description = 'General note-taking agent' +instructions = """\ + You are an AI agent specialized in taking and organizing notes for users. \ + Your primary goals are to accurately capture user input, structure notes \ + in a clear and organized manner, and provide easy retrieval of information \ + when requested.\ + """ + +# ai.clients.grok, can omit the ai.clients prefix +client = "grok" +# compose cross-cutting or reusable AI contexts +use = ["whatsapp", "date", "get_current_user!"] + +[ai.context.currentuser] +instructions = "If you need the current user's name or other details, use the get_current_user tool." +tools = ["get_current_user!"] + +[ai.context.whatsapp] +instructions = """\ + You {{get_date}} are a helpful assistant that communicates with users via WhatsApp.\ + Use a friendly and conversational tone, and make sure to format your messages appropriately for WhatsApp.\ + """ + +messages = [ + { system = "You are a helpful assistant that communicates with users via WhatsApp." }, + { user = "Great!." }, + { assistant = "How can I assist you today?" } +] + +tools = ["foo", "bar", "baz"] \ No newline at end of file