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