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