Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/agents/notes.agent.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions .github/agents/notes.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
Binary file added assets/img/agent-model.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 27 additions & 10 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 6 additions & 13 deletions sample/Server/ai.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """\
Expand Down
7 changes: 4 additions & 3 deletions sample/Server/notes.md → sample/Server/notes.agent.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions sample/ServiceDefaults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public static TBuilder ConfigureReload<TBuilder>(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
{
Expand All @@ -111,7 +111,7 @@ public static TBuilder ConfigureReload<TBuilder>(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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
namespace Devlooped.Agents.AI;

[EditorBrowsable(EditorBrowsableState.Never)]
public static class ConfigurableInstructionsExtensions
public static class AgentMarkdownExtensions
{
/// <summary>
/// Adds an instructions markdown file with optional YAML front-matter to the configuration sources.
/// </summary>
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<InstructionsConfigurationSource>(source =>
{
source.Path = path;
Expand All @@ -23,7 +23,7 @@ public static IConfigurationBuilder AddInstructionsFile(this IConfigurationBuild
/// <summary>
/// Adds an instructions markdown stream with optional YAML front-matter to the configuration sources.
/// </summary>
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
Expand Down
28 changes: 17 additions & 11 deletions src/Agents/ConfigurableAIAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> 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);

Expand All @@ -127,10 +129,10 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> 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<AIContext>();
foreach (var use in options.Use)
foreach (var use in options.Use ?? [])
{
var context = services.GetKeyedService<AIContext>(use);
if (context is not null)
Expand All @@ -139,13 +141,6 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum
continue;
}

var function = services.GetKeyedService<AITool>(use) ?? services.GetKeyedService<AIFunction>(use);
if (function is not null)
{
contexts.Add(new AIContext { Tools = [function] });
continue;
}

if (configuration.GetSection("ai:context:" + use) is { } ctxSection &&
ctxSection.Get<AIContextConfiguration>() is { } ctxConfig)
{
Expand All @@ -161,7 +156,7 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum
{
var tool = services.GetKeyedService<AITool>(toolName) ??
services.GetKeyedService<AIFunction>(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);
Expand All @@ -172,7 +167,16 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> 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<AITool>(toolName) ??
services.GetKeyedService<AIFunction>(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);
Expand Down Expand Up @@ -215,7 +219,9 @@ void OnReload(object? state)
internal class AgentClientOptions : ChatClientAgentOptions
{
public string? Client { get; set; }
public string? Model { get; set; }
public IList<string>? Use { get; set; }
public IList<string>? Tools { get; set; }
}
}

Expand Down
80 changes: 79 additions & 1 deletion src/Tests/ConfigurableAgentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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<Exception>(() => app.Services.GetRequiredKeyedService<AIAgent>("chat"));

Assert.Contains("get_date", exception.Message);
Assert.Contains("ai:agents:chat", exception.Message);
}

[Fact]
public async Task UseAIContextFromSection()
{
Expand Down Expand Up @@ -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<AIAgent>("chat");
var options = agent.GetService<ChatClientAgentOptions>();

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<AIAgent>("chat");
var options = agent.GetService<ChatClientAgentOptions>();

Assert.Equal("gpt-5", options?.ChatOptions?.ModelId);
}
}

2 changes: 1 addition & 1 deletion src/Tests/Misc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
Loading