diff --git a/src/Agents/CompositeAIContextProvider.cs b/src/Agents/CompositeAIContextProvider.cs
new file mode 100644
index 0000000..ca0d181
--- /dev/null
+++ b/src/Agents/CompositeAIContextProvider.cs
@@ -0,0 +1,52 @@
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+namespace Devlooped.Agents.AI;
+
+///
+/// Concatenates multiple instances into a single one.
+///
+class CompositeAIContextProvider : AIContextProvider
+{
+ readonly AIContext context;
+
+ public CompositeAIContextProvider(IList contexts)
+ {
+ if (contexts.Count == 1)
+ {
+ context = contexts[0];
+ return;
+ }
+
+ // Concatenate instructions from all contexts
+ context = new();
+ var instructions = new List();
+ var messages = new List();
+ var tools = new List();
+
+ foreach (var ctx in contexts)
+ {
+ if (!string.IsNullOrEmpty(ctx.Instructions))
+ instructions.Add(ctx.Instructions);
+
+ if (ctx.Messages != null)
+ messages.AddRange(ctx.Messages);
+
+ if (ctx.Tools != null)
+ tools.AddRange(ctx.Tools);
+ }
+
+ // Same separator used by M.A.AI for instructions appending from AIContext
+ if (instructions.Count > 0)
+ context.Instructions = string.Join('\n', instructions);
+
+ if (messages.Count > 0)
+ context.Messages = messages;
+
+ if (tools.Count > 0)
+ context.Tools = tools;
+ }
+
+ public override ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ => ValueTask.FromResult(this.context);
+}
\ No newline at end of file
diff --git a/src/Agents/ConfigurableAIAgent.cs b/src/Agents/ConfigurableAIAgent.cs
index 4701ad7..818a684 100644
--- a/src/Agents/ConfigurableAIAgent.cs
+++ b/src/Agents/ConfigurableAIAgent.cs
@@ -113,7 +113,45 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum
services.GetService();
if (contextFactory is not null)
+ {
+ if (options.Use?.Count > 0)
+ throw new InvalidOperationException($"Invalid simultaneous use of keyed service {nameof(AIContextProviderFactory)} and '{section}:use' in configuration.");
+
options.AIContextProviderFactory = contextFactory.CreateProvider;
+ }
+ else if (services.GetKeyedService(name) is { } contextProvider)
+ {
+ if (options.Use?.Count > 0)
+ throw new InvalidOperationException($"Invalid simultaneous use of keyed service {nameof(AIContextProvider)} and '{section}:use' in configuration.");
+
+ options.AIContextProviderFactory = _ => contextProvider;
+ }
+ else if (options.Use?.Count > 0)
+ {
+ var contexts = new List();
+ foreach (var use in options.Use)
+ {
+ var context = services.GetKeyedService(use);
+ if (context is null)
+ {
+ var function = services.GetKeyedService(use) ??
+ services.GetKeyedService(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(new AIContext { Tools = [function] });
+ }
+ else
+ {
+ contexts.Add(context);
+ }
+ }
+
+ options.AIContextProviderFactory = _ => new CompositeAIContextProvider(contexts);
+ }
+ }
+ else if (options.Use?.Count > 0)
+ {
+ throw new InvalidOperationException($"Invalid simultaneous use of {nameof(ChatClientAgentOptions)}.{nameof(ChatClientAgentOptions.AIContextProviderFactory)} and '{section}:use' in configuration.");
}
if (options.ChatMessageStoreFactory is null)
@@ -148,6 +186,7 @@ void OnReload(object? state)
internal class AgentClientOptions : ChatClientAgentOptions
{
public string? Client { get; set; }
+ public IList? Use { get; set; }
}
}
diff --git a/src/Tests/ConfigurableAgentTests.cs b/src/Tests/ConfigurableAgentTests.cs
index 66f542d..ab10616 100644
--- a/src/Tests/ConfigurableAgentTests.cs
+++ b/src/Tests/ConfigurableAgentTests.cs
@@ -6,6 +6,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Moq;
+using Tomlyn.Extensions.Configuration;
namespace Devlooped.Agents.AI;
@@ -178,34 +179,6 @@ public void CanReloadConfiguration()
Assert.Equal("xai", agent.GetService()?.ProviderName);
}
- [Fact]
- public void AssignsContextProviderFromKeyedService()
- {
- var builder = new HostApplicationBuilder();
- var context = Mock.Of();
-
- builder.Services.AddKeyedSingleton("bot",
- Mock.Of(x
- => x.CreateProvider(It.IsAny()) == context));
-
- builder.Configuration.AddInMemoryCollection(new Dictionary
- {
- ["ai:clients:chat:modelid"] = "gpt-4.1-nano",
- ["ai:clients:chat:apikey"] = "sk-asdfasdf",
- ["ai:agents:bot:client"] = "chat",
- ["ai:agents:bot:options:temperature"] = "0.5",
- });
-
- builder.AddAIAgents();
-
- var app = builder.Build();
- var agent = app.Services.GetRequiredKeyedService("bot");
- var options = agent.GetService();
-
- Assert.NotNull(options?.AIContextProviderFactory);
- Assert.Same(context, options?.AIContextProviderFactory?.Invoke(new ChatClientAgentOptions.AIContextProviderFactoryContext()));
- }
-
[Fact]
public void AssignsContextProviderFromService()
{
@@ -350,12 +323,248 @@ public void CanSetGrokOptions()
}
[Fact]
- public void Task()
+ public void UseContextProviderFactoryFromKeyedService()
+ {
+ var builder = new HostApplicationBuilder();
+ var context = Mock.Of();
+
+ builder.Services.AddKeyedSingleton("bot",
+ Mock.Of(x
+ => x.CreateProvider(It.IsAny()) == context));
+
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ ["ai:clients:chat:modelid"] = "gpt-4.1-nano",
+ ["ai:clients:chat:apikey"] = "sk-asdfasdf",
+ ["ai:agents:bot:client"] = "chat",
+ ["ai:agents:bot:options:temperature"] = "0.5",
+ });
+
+ builder.AddAIAgents();
+
+ var app = builder.Build();
+ var agent = app.Services.GetRequiredKeyedService("bot");
+ var options = agent.GetService();
+
+ Assert.NotNull(options?.AIContextProviderFactory);
+ Assert.Same(context, options?.AIContextProviderFactory?.Invoke(new ChatClientAgentOptions.AIContextProviderFactoryContext()));
+ }
+
+ [Fact]
+ public async Task UseContextProviderFromKeyedServiceAsync()
+ {
+ var builder = new HostApplicationBuilder();
+ var context = new AIContext();
+
+ var provider = new Mock();
+ provider
+ .Setup(x => x.InvokingAsync(It.IsAny(), default(CancellationToken)))
+ .ReturnsAsync(context);
+
+ builder.Services.AddKeyedSingleton("chat", provider.Object);
+
+ builder.Configuration.AddToml(
+ """"
+ [ai.clients.openai]
+ modelid = "gpt-4.1"
+ apikey = "sk-asdf"
+
+ [ai.agents.chat]
+ description = "Chat agent."
+ client = "openai"
+ """");
+
+ builder.AddAIAgents();
+
+ var app = builder.Build();
+ var agent = app.Services.GetRequiredKeyedService("chat");
+ var options = agent.GetService();
+
+ Assert.NotNull(options?.AIContextProviderFactory);
+
+ var actualProvider = options?.AIContextProviderFactory?.Invoke(new());
+
+ Assert.NotNull(actualProvider);
+
+ Assert.Same(context, await actualProvider.InvokingAsync(new([]), default));
+ }
+
+ [Fact]
+ public void UseAndContextProviderFactoryIncompatible()
{
- var agent = new AgentRunResponse() { AgentId = "agent-123" };
- var chat = agent.AsChatResponse();
+ 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"
+ use = ["voseo"]
+
+ [ai.context.voseo]
+ 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".
+ """
+ """");
+
+ builder.AddAIAgents(configureOptions: (name, options)
+ => options.AIContextProviderFactory = context => Mock.Of());
+
+ var app = builder.Build();
+
+ var exception = Assert.ThrowsAny(() => app.Services.GetRequiredKeyedService("chat"));
+
+ Assert.Contains("ai:agents:chat:use", exception.Message);
+ }
+
+ [Fact]
+ public void UseAndContextProviderIncompatible()
+ {
+ 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"
+ use = ["voseo"]
+
+ [ai.context.voseo]
+ 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".
+ """
+ """");
+
+ builder.Services.AddKeyedSingleton("chat", Mock.Of());
+ builder.AddAIAgents();
+
+ var app = builder.Build();
+ var exception = Assert.ThrowsAny(() => app.Services.GetRequiredKeyedService("chat"));
+
+ Assert.Contains("ai:agents:chat:use", exception.Message);
+ }
+
+ [Fact]
+ public async Task UseAIContextFromKeyedServiceAsync()
+ {
+ var builder = new HostApplicationBuilder();
+ var voseo = new AIContext { Instructions = "voseo" };
+
+ builder.Configuration.AddToml(
+ """"
+ [ai.clients.openai]
+ modelid = "gpt-4.1"
+ apikey = "sk-asdf"
+
+ [ai.agents.chat]
+ description = "Chat agent."
+ client = "openai"
+ use = ["voseo"]
+ """");
+
+ builder.Services.AddKeyedSingleton("voseo", voseo);
+
+ builder.AddAIAgents();
+
+ var app = builder.Build();
+ var agent = app.Services.GetRequiredKeyedService("chat");
+ var options = agent.GetService();
+
+ Assert.NotNull(options?.AIContextProviderFactory);
+ var actualProvider = options?.AIContextProviderFactory?.Invoke(new());
+ Assert.NotNull(actualProvider);
+
+ var actualContext = await actualProvider.InvokingAsync(new([]), default);
+
+ Assert.Same(voseo, await actualProvider.InvokingAsync(new([]), default));
+ }
+
+ [Fact]
+ public async Task UseAggregatedAIContextsFromKeyedServiceAsync()
+ {
+ var builder = new HostApplicationBuilder();
+ var voseo = new AIContext { Instructions = "voseo" };
+ var formatting = new AIContext { Instructions = "formatting" };
+
+ builder.Configuration.AddToml(
+ """"
+ [ai.clients.openai]
+ modelid = "gpt-4.1"
+ apikey = "sk-asdf"
+
+ [ai.agents.chat]
+ description = "Chat agent."
+ client = "openai"
+ use = ["voseo", "formatting"]
+ """");
+
+ builder.Services.AddKeyedSingleton("voseo", voseo);
+ builder.Services.AddKeyedSingleton("formatting", formatting);
+
+ builder.AddAIAgents();
+
+ var app = builder.Build();
+ var agent = app.Services.GetRequiredKeyedService("chat");
+ var options = agent.GetService();
+
+ Assert.NotNull(options?.AIContextProviderFactory);
+ var actualProvider = options?.AIContextProviderFactory?.Invoke(new());
+ Assert.NotNull(actualProvider);
+
+ var actualContext = await actualProvider.InvokingAsync(new([]), default);
+
+ Assert.StartsWith(voseo.Instructions, actualContext.Instructions);
+ Assert.EndsWith(formatting.Instructions, actualContext.Instructions);
+ }
+
+ [Fact]
+ public async Task UseAIToolFromKeyedServiceAsync()
+ {
+ 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"
+ use = ["get_date"]
+ """");
+
+ AITool tool = AIFunctionFactory.Create(() => DateTimeOffset.Now, "get_date");
+ builder.Services.AddKeyedSingleton("get_date", tool);
+ builder.AddAIAgents();
+
+ var app = builder.Build();
+ var agent = app.Services.GetRequiredKeyedService("chat");
+ var options = agent.GetService();
+
+ Assert.NotNull(options?.AIContextProviderFactory);
+ var provider = options?.AIContextProviderFactory?.Invoke(new());
+ Assert.NotNull(provider);
+
+ var context = await provider.InvokingAsync(new([]), default);
- Assert.Equal("agent-123", chat.AdditionalProperties!["AgentId"]);
+ Assert.NotNull(context.Tools);
+ Assert.Single(context.Tools);
+ Assert.Same(tool, context.Tools[0]);
}
}
diff --git a/src/Tests/Extensions/Configuration.cs b/src/Tests/Extensions/Configuration.cs
index a8f0d29..17a2e04 100644
--- a/src/Tests/Extensions/Configuration.cs
+++ b/src/Tests/Extensions/Configuration.cs
@@ -1,10 +1,15 @@
using System.Reflection;
+using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
+using Tomlyn.Extensions.Configuration;
public static class ConfigurationExtensions
{
+ public static IConfigurationBuilder AddToml(this IConfigurationBuilder builder, string contents)
+ => builder.AddTomlStream(new MemoryStream(Encoding.UTF8.GetBytes(contents)));
+
public static IConfiguration Configuration { get; } = new ConfigurationBuilder()
.AddEnvironmentVariables()
.AddUserSecrets(Assembly.GetExecutingAssembly())
diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj
index b7e726d..0244853 100644
--- a/src/Tests/Tests.csproj
+++ b/src/Tests/Tests.csproj
@@ -5,6 +5,7 @@
OPENAI001;$(NoWarn)
Preview
true
+ Devlooped
@@ -24,6 +25,8 @@
+
+
@@ -38,8 +41,10 @@
+
+