Skip to content

Commit 3cd5546

Browse files
committed
Allow aggregation of configured, static and dynamic contexts
We previously had aggregation only of static (either exported or configured) AIContexts. This was too limiting, however. So we now allow also an exported AIContextProvider to also be used as part of the `use` context pulled-in by an agent.
1 parent 670eed4 commit 3cd5546

File tree

5 files changed

+201
-17
lines changed

5 files changed

+201
-17
lines changed

readme.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ and `AIContext`. This package supports dynamic extension of a configured agent i
188188
in code.
189189
2. A keyed service `AIContextProvider` with the same name as the agent.
190190
3. A keyed service `AIContext` with the same name as the agent.
191-
4. Configured `AIContext` sections pulled in via `use` setting for an agent.
191+
4. Aggregate of AI contexts pulled in via `use` setting for an agent.
192192

193193
The first three alternatives enable auto-wiring of context providers or contexts registered in the service collection and
194194
are pretty self-explanatory. The last alternative allows even more declarative scenarios involving reusable and cross-cutting
@@ -235,6 +235,12 @@ tools = ["get_date"]
235235

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

238+
In addition to configured sections, the `use` property can also reference exported contexts as either `AIContext`
239+
(for static context) or `AIContextProvider` (for dynamic context) registered in DI with a matching name.
240+
241+
242+
### Extensible Tools
243+
238244
The `tools` section allows specifying tool names registered in the DI container, such as:
239245

240246
```csharp

src/Agents/CompositeAIContextProvider.cs

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,69 @@ namespace Devlooped.Agents.AI;
88
/// </summary>
99
class CompositeAIContextProvider : AIContextProvider
1010
{
11-
readonly AIContext context;
11+
readonly IList<AIContextProvider> providers;
12+
readonly AIContext? staticContext;
1213

13-
public CompositeAIContextProvider(IList<AIContext> contexts)
14+
public CompositeAIContextProvider(IList<AIContextProvider> providers)
1415
{
15-
if (contexts.Count == 1)
16+
this.providers = providers;
17+
18+
// Special case for single provider of static contexts
19+
if (providers.Count == 1 && providers[0] is StaticAIContextProvider staticProvider)
1620
{
17-
context = contexts[0];
21+
staticContext = staticProvider.Context;
1822
return;
1923
}
2024

21-
// Concatenate instructions from all contexts
22-
context = new();
25+
// Special case where all providers are static
26+
if (providers.All(x => x is StaticAIContextProvider))
27+
{
28+
// Concatenate instructions from all contexts
29+
staticContext = new();
30+
var instructions = new List<string>();
31+
var messages = new List<ChatMessage>();
32+
var tools = new List<AITool>();
33+
34+
foreach (var provider in providers.Cast<StaticAIContextProvider>())
35+
{
36+
var ctx = provider.Context;
37+
38+
if (!string.IsNullOrEmpty(ctx.Instructions))
39+
instructions.Add(ctx.Instructions);
40+
41+
if (ctx.Messages != null)
42+
messages.AddRange(ctx.Messages);
43+
44+
if (ctx.Tools != null)
45+
tools.AddRange(ctx.Tools);
46+
}
47+
48+
// Same separator used by M.A.AI for instructions appending from AIContext
49+
if (instructions.Count > 0)
50+
staticContext.Instructions = string.Join('\n', instructions);
51+
52+
if (messages.Count > 0)
53+
staticContext.Messages = messages;
54+
55+
if (tools.Count > 0)
56+
staticContext.Tools = tools;
57+
}
58+
}
59+
60+
public override async ValueTask<AIContext> InvokingAsync(InvokingContext invoking, CancellationToken cancellationToken = default)
61+
{
62+
if (staticContext is not null)
63+
return staticContext;
64+
65+
var context = new AIContext();
2366
var instructions = new List<string>();
2467
var messages = new List<ChatMessage>();
2568
var tools = new List<AITool>();
2669

27-
foreach (var ctx in contexts)
70+
foreach (var provider in providers)
2871
{
72+
var ctx = await provider.InvokingAsync(invoking, cancellationToken);
73+
2974
if (!string.IsNullOrEmpty(ctx.Instructions))
3075
instructions.Add(ctx.Instructions);
3176

@@ -45,8 +90,7 @@ public CompositeAIContextProvider(IList<AIContext> contexts)
4590

4691
if (tools.Count > 0)
4792
context.Tools = tools;
48-
}
4993

50-
public override ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
51-
=> ValueTask.FromResult(this.context);
94+
return context;
95+
}
5296
}

src/Agents/ConfigurableAIAgent.cs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,16 +148,21 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum
148148
}
149149
else if (options.Use?.Count > 0 || options.Tools?.Count > 0)
150150
{
151-
var contexts = new List<AIContext>();
151+
var contexts = new List<AIContextProvider>();
152152
foreach (var use in options.Use ?? [])
153153
{
154-
var context = services.GetKeyedService<AIContext>(use);
155-
if (context is not null)
154+
if (services.GetKeyedService<AIContext>(use) is { } staticContext)
156155
{
157-
contexts.Add(context);
156+
contexts.Add(new StaticAIContextProvider(staticContext));
157+
continue;
158+
}
159+
else if (services.GetKeyedService<AIContextProvider>(use) is { } dynamicContext)
160+
{
161+
contexts.Add(dynamicContext);
158162
continue;
159163
}
160164

165+
// Else, look for a config section.
161166
if (configuration.GetSection("ai:context:" + use) is { } ctxSection &&
162167
ctxSection.Get<AIContextConfiguration>() is { } ctxConfig)
163168
{
@@ -180,7 +185,7 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum
180185
}
181186
}
182187

183-
contexts.Add(configured);
188+
contexts.Add(new StaticAIContextProvider(configured));
184189
continue;
185190
}
186191

@@ -193,7 +198,7 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum
193198
services.GetKeyedService<AIFunction>(toolName) ??
194199
throw new InvalidOperationException($"Specified tool '{toolName}' for agent '{section}' is not registered as a keyed {nameof(AITool)} or {nameof(AIFunction)}.");
195200

196-
contexts.Add(new AIContext { Tools = [tool] });
201+
contexts.Add(new StaticAIContextProvider(new AIContext { Tools = [tool] }));
197202
}
198203

199204
options.AIContextProviderFactory = _ => new CompositeAIContextProvider(contexts);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using Microsoft.Agents.AI;
2+
3+
namespace Devlooped.Agents.AI;
4+
5+
class StaticAIContextProvider(AIContext context) : AIContextProvider
6+
{
7+
public AIContext Context => context;
8+
9+
public override ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
10+
=> ValueTask.FromResult(Context);
11+
}

src/Tests/ConfigurableAgentTests.cs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,124 @@ public async Task UseAIContextFromSection()
645645
Assert.Same(tool, context.Tools?.First());
646646
}
647647

648+
[Fact]
649+
public async Task UseAIContextFromProvider()
650+
{
651+
var builder = new HostApplicationBuilder();
652+
var voseo =
653+
"""
654+
Default to using spanish language, using argentinean "voseo" in your responses.
655+
""";
656+
657+
builder.Configuration.AddToml(
658+
$$"""
659+
[ai.clients.openai]
660+
modelid = "gpt-4.1"
661+
apikey = "sk-asdf"
662+
663+
[ai.agents.chat]
664+
description = "Chat agent."
665+
client = "openai"
666+
use = ["default"]
667+
""");
668+
669+
var tool = AIFunctionFactory.Create(() => DateTimeOffset.Now, "get_date");
670+
builder.Services.AddKeyedSingleton("default", Mock.Of<AIContextProvider>(x
671+
=> x.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), default) == ValueTask.FromResult(new AIContext
672+
{
673+
Instructions = voseo,
674+
Tools = new[] { tool }
675+
})));
676+
677+
builder.AddAIAgents();
678+
var app = builder.Build();
679+
680+
var agent = app.Services.GetRequiredKeyedService<AIAgent>("chat");
681+
var options = agent.GetService<ChatClientAgentOptions>();
682+
683+
Assert.NotNull(options?.AIContextProviderFactory);
684+
var provider = options?.AIContextProviderFactory?.Invoke(new());
685+
Assert.NotNull(provider);
686+
687+
var context = await provider.InvokingAsync(new([]), default);
688+
689+
Assert.NotNull(context.Instructions);
690+
Assert.Equal(voseo, context.Instructions);
691+
Assert.Same(tool, context.Tools?.First());
692+
}
693+
694+
[Fact]
695+
public async Task CombineAIContextFromStaticDinamicAndSection()
696+
{
697+
var builder = new HostApplicationBuilder();
698+
699+
builder.Configuration.AddToml(
700+
$$"""
701+
[ai.clients.openai]
702+
modelid = "gpt-4.1"
703+
apikey = "sk-asdf"
704+
705+
[ai.agents.chat]
706+
description = "Chat agent."
707+
client = "openai"
708+
use = ["default", "static", "dynamic"]
709+
710+
[ai.context.default]
711+
instructions = 'foo'
712+
messages = [
713+
{ system = "You are strictly professional." },
714+
{ user = "Hey you!"},
715+
{ assistant = "Hello there. How can I assist you today?" }
716+
]
717+
tools = ["get_date"]
718+
""");
719+
720+
var tool = AIFunctionFactory.Create(() => DateTimeOffset.Now, "get_date");
721+
builder.Services.AddKeyedSingleton("get_date", tool);
722+
723+
builder.Services.AddKeyedSingleton("static", new AIContext
724+
{
725+
Instructions = "bar",
726+
Tools = new AITool[] { AIFunctionFactory.Create(() => "bar", "get_bar") }
727+
});
728+
729+
AITool[] getbaz = [AIFunctionFactory.Create(() => "baz", "get_baz")];
730+
731+
builder.Services.AddKeyedSingleton("dynamic", Mock.Of<AIContextProvider>(x
732+
=> x.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), default) == ValueTask.FromResult(new AIContext
733+
{
734+
Instructions = "baz",
735+
Tools = getbaz
736+
})));
737+
738+
builder.AddAIAgents();
739+
var app = builder.Build();
740+
741+
var agent = app.Services.GetRequiredKeyedService<AIAgent>("chat");
742+
var options = agent.GetService<ChatClientAgentOptions>();
743+
744+
Assert.NotNull(options?.AIContextProviderFactory);
745+
var provider = options?.AIContextProviderFactory?.Invoke(new());
746+
Assert.NotNull(provider);
747+
748+
var context = await provider.InvokingAsync(new([]), default);
749+
750+
Assert.NotNull(context.Instructions);
751+
Assert.Contains("foo", context.Instructions);
752+
Assert.Contains("bar", context.Instructions);
753+
Assert.Contains("baz", context.Instructions);
754+
755+
Assert.Equal(3, context.Messages?.Count);
756+
Assert.Single(context.Messages!, x => x.Role == ChatRole.System && x.Text == "You are strictly professional.");
757+
Assert.Single(context.Messages!, x => x.Role == ChatRole.User && x.Text == "Hey you!");
758+
Assert.Single(context.Messages!, x => x.Role == ChatRole.Assistant && x.Text == "Hello there. How can I assist you today?");
759+
760+
Assert.NotNull(context.Tools);
761+
Assert.Contains(tool, context.Tools!);
762+
Assert.Contains(context.Tools, x => x.Name == "get_bar");
763+
Assert.Contains(context.Tools, x => x.Name == "get_baz");
764+
}
765+
648766
[Fact]
649767
public async Task MissingToolAIContextFromSectionThrows()
650768
{

0 commit comments

Comments
 (0)