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
82 changes: 80 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,8 @@ modelid = "grok-4-fast-non-reasoning"
[ai.agents.orders]
description = "Manage orders using catalogs for food or any other item."
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.

"""

# ai.clients.openai, can omit the ai.clients prefix
Expand All @@ -135,6 +133,86 @@ 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.

### Extensible AI Contexts

The Microsoft [agent framework](https://learn.microsoft.com/en-us/agent-framework/overview/agent-framework-overview) allows extending
agents with dynamic context via [AIContextProvider](https://learn.microsoft.com/en-us/dotnet/api/microsoft.agents.ai.aicontextprovider)
and `AIContext`. This package supports dynamic extension of a configured agent in the following ways (in order of priority):

1. A keyed service `AIContextProviderFactory` with the same name as the agent will be set up just as if you had
set it manually as the [ChatClientAgentOptions.AIContextProviderFactory](https://learn.microsoft.com/en-us/dotnet/api/microsoft.agents.ai.chatclientagentoptions.aicontextproviderfactory)
in code.
2. A keyed service `AIContextProvider` with the same name as the agent.
3. A keyed service `AIContext` with the same name as the agent.
4. Configured `AIContext` sections pulled in via `use` setting for an agent.

The first three alternatives enable auto-wiring of context providers or contexts registered in the service collection and
are pretty self-explanatory. The last alternative allows even more declarative scenarios involving reusable and cross-cutting
context definitions.

For example, let's say you want to provide consistent tone for all your agents. It would be tedious, repetitive and harder
to maintain if you had to set that in each agent's instructions. Instead, you can define a reusable context named `tone` such as:

```toml
[ai.context.tone]
instructions = """\
Default to using spanish language, using argentinean "voseo" in your responses \
(unless the user explicitly talks in a different language). \
This means using "vos" instead of "tú" and conjugating verbs accordingly. \
Don't use the expression "pa'" instead of "para". Don't mention the word "voseo".
"""
```

Then, you can reference that context in any agent using the `use` setting:
```toml
[ai.agents.support]
description = "An AI agent that helps with customer support."
instructions = "..."
client = "grok"
use = ["tone"]

[ai.agents.sales]
description = "An AI agent that helps with sales inquiries."
instructions = "..."
client = "openai"
use = ["tone"]
```

Configured contexts can provide all three components of an `AIContext`: instructions, messages and tools, such as:

```toml
[ai.context.timezone]
instructions = "Always assume the user's timezone is America/Argentina/Buenos_Aires unless specified otherwise."
messages = [
{ system = "You are aware of the current date and time in America/Argentina/Buenos_Aires." }
]
tools = ["get_date"]
```

If multiple contexts are specified in `use`, they are applied in order, concatenating their instructions, messages and tools.

The `tools` section allows specifying tool names registered in the DI container, such as:

```csharp
services.AddKeyedSingleton("get_date", AIFunctionFactory.Create(() => DateTimeOffset.Now, "get_date"));
```

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:

```toml
[ai.agents.support]
description = "An AI agent that helps with customer support."
instructions = "..."
client = "grok"
use = ["tone", "get_date"]
```

This enables a flexible and convenient mix of static and dynamic context for agents, all driven
from configuration.


<!-- #agents -->

Expand Down
66 changes: 43 additions & 23 deletions sample/Server/ConsoleExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,65 @@
using Microsoft.Agents.AI.Hosting;
using Microsoft.Extensions.AI;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using Spectre.Console;
using Spectre.Console.Json;

public static class ConsoleExtensions
{
public static async ValueTask RenderAgentsAsync(this IServiceProvider services, IServiceCollection collection)
extension(IServiceProvider services)
{
var catalog = services.GetRequiredService<AgentCatalog>();
var settings = new JsonSerializerSettings
public async ValueTask RenderAgentsAsync(IServiceCollection collection)
{
NullValueHandling = NullValueHandling.Include,
DefaultValueHandling = DefaultValueHandling.Ignore
};
var catalog = services.GetRequiredService<AgentCatalog>();
var settings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Include,
DefaultValueHandling = DefaultValueHandling.Ignore,
ContractResolver = new IgnoreDelegatePropertiesResolver(),
};

// List configured clients
foreach (var description in collection.AsEnumerable().Where(x => x.ServiceType == typeof(IChatClient) && x.IsKeyedService && x.ServiceKey is string))
{
var client = services.GetKeyedService<IChatClient>(description.ServiceKey);
if (client is null)
continue;
// List configured clients
foreach (var description in collection.AsEnumerable().Where(x => x.ServiceType == typeof(IChatClient) && x.IsKeyedService && x.ServiceKey is string))
{
var client = services.GetKeyedService<IChatClient>(description.ServiceKey);
if (client is null)
continue;

var metadata = client.GetService<ConfigurableChatClientMetadata>();
var chatopt = (client as ConfigurableChatClient)?.Options;

var metadata = client.GetService<ConfigurableChatClientMetadata>();
var chatopt = (client as ConfigurableChatClient)?.Options;
AnsiConsole.Write(new Panel(new JsonText(JsonConvert.SerializeObject(new { Metadata = metadata, Options = chatopt }, settings)))
{
Header = new PanelHeader($"| 💬 {metadata?.Id} from {metadata?.ConfigurationSection} |"),
});
}

AnsiConsole.Write(new Panel(new JsonText(JsonConvert.SerializeObject(new { Metadata = metadata, Options = chatopt }, settings)))
// List configured agents
await foreach (var agent in catalog.GetAgentsAsync())
{
Header = new PanelHeader($"| 💬 {metadata?.Id} from {metadata?.ConfigurationSection} |"),
});
var metadata = agent.GetService<ConfigurableAIAgentMetadata>();

AnsiConsole.Write(new Panel(new JsonText(JsonConvert.SerializeObject(new { Agent = agent, Metadata = metadata }, settings)))
{
Header = new PanelHeader($"| 🤖 {agent.DisplayName} from {metadata?.ConfigurationSection} |"),
});
}
}
}

// List configured agents
await foreach (var agent in catalog.GetAgentsAsync())
class IgnoreDelegatePropertiesResolver : DefaultContractResolver
{
protected override JsonProperty CreateProperty(System.Reflection.MemberInfo member, MemberSerialization memberSerialization)
{
var metadata = agent.GetService<ConfigurableAIAgentMetadata>();
var property = base.CreateProperty(member, memberSerialization);

AnsiConsole.Write(new Panel(new JsonText(JsonConvert.SerializeObject(new { Agent = agent, Metadata = metadata }, settings)))
if (property.PropertyType != null && typeof(Delegate).IsAssignableFrom(property.PropertyType))
{
Header = new PanelHeader($"| 🤖 {agent.DisplayName} from {metadata?.ConfigurationSection} |"),
});
property.ShouldSerialize = _ => false;
}

return property;
}
}
}
5 changes: 3 additions & 2 deletions sample/Server/Program.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
using System.Runtime.InteropServices;
using System.Text;
using Devlooped.Extensions.AI;
using DotNetEnv.Configuration;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.OpenAI;
using Microsoft.Extensions.AI;
using Spectre.Console;
using Tomlyn.Extensions.Configuration;

var builder = WebApplication.CreateBuilder(args);

Expand All @@ -20,6 +18,9 @@
builder.AddServiceDefaults();
builder.ConfigureReload();

// 👇 showcases using dynamic AI context from configuration
builder.Services.AddKeyedSingleton("get_date", AIFunctionFactory.Create(() => DateTimeOffset.UtcNow, "get_date"));

// 👇 implicitly calls AddChatClients
builder.AddAIAgents();

Expand Down
13 changes: 11 additions & 2 deletions sample/Server/ai.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[ai.clients.openai]
[ai.clients.openai]
modelid = "gpt-4.1"

[ai.clients.grok]
Expand All @@ -7,14 +7,23 @@ modelid = "grok-4-fast-non-reasoning"

[ai.agents.orders]
description = "Manage orders using catalogs for food or any other item."
instructions = """
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.
"""

# ai.clients.openai, can omit the ai.clients prefix
client = "openai"
use = ["tone", "get_date"]

[ai.agents.reminder.options]
modelid = "gpt-4o-mini"
# additional properties could be added here

[ai.context.tone]
instructions = """\
Default to using spanish language, using argentinean "voseo" in your responses \
(unless the user explicitly talks in a different language). \
This means using "vos" instead of "tú" and conjugating verbs accordingly. \
Don't use the expression "pa'" instead of "para". Don't mention the word "voseo".
"""
4 changes: 4 additions & 0 deletions sample/ServiceDefaults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder)
return builder;
}

/// <summary>
/// Configures automatic configuration reload from either the build output directory (production)
/// or from the project directory (development).
/// </summary>
public static TBuilder ConfigureReload<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
Expand Down
66 changes: 58 additions & 8 deletions src/Agents/ConfigurableAIAgent.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Diagnostics;
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Devlooped.Extensions.AI;
using Devlooped.Extensions.AI.Grok;
Expand Down Expand Up @@ -132,18 +134,46 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum
foreach (var use in options.Use)
{
var context = services.GetKeyedService<AIContext>(use);
if (context is null)
if (context is not null)
{
var function = services.GetKeyedService<AITool>(use) ??
services.GetKeyedService<AIFunction>(use) ??
throw new InvalidOperationException($"Specified AI context '{use}' for agent '{name}' is not registered as either an {nameof(AIContent)} or an {nameof(AITool)}.");
contexts.Add(context);
continue;
}

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

if (configuration.GetSection("ai:context:" + use) is { } ctxSection &&
ctxSection.Get<AIContextConfiguration>() is { } ctxConfig)
{
contexts.Add(context);
var configured = new AIContext();
if (ctxConfig.Instructions is not null)
configured.Instructions = ctxConfig.Instructions.Dedent();
if (ctxConfig.Messages is { Count: > 1 } messages)
configured.Messages = messages;

if (ctxConfig.Tools is not null)
{
foreach (var toolName in ctxConfig.Tools)
{
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}'.");

configured.Tools ??= [];
configured.Tools.Add(tool);
}
}

contexts.Add(configured);
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}'.");
}

options.AIContextProviderFactory = _ => new CompositeAIContextProvider(contexts);
Expand Down Expand Up @@ -199,4 +229,24 @@ public class ConfigurableAIAgentMetadata(string name, string configurationSectio
public string Name => name;
/// <summary>Configuration section where the agent is defined.</summary>
public string ConfigurationSection = configurationSection;
}
}

class AIContextConfiguration
{
public string? Instructions { get; set; }

public IList<ChatMessage>? Messages =>
MessageConfigurations?.Select(config =>
config.System is not null ? new ChatMessage(ChatRole.System, config.System) :
config.User is not null ? new ChatMessage(ChatRole.User, config.User) :
config.Assistant is not null ? new ChatMessage(ChatRole.Assistant, config.Assistant) :
null).Where(x => x is not null).Cast<ChatMessage>().ToList();

public IList<string>? Tools { get; set; }

[EditorBrowsable(EditorBrowsableState.Never)]
[ConfigurationKeyName("Messages")]
public MessageConfiguration[]? MessageConfigurations { get; set; }
}

record MessageConfiguration(string? System = default, string? User = default, string? Assistant = default);
Loading
Loading