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
8 changes: 7 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ and `AIContext`. This package supports dynamic extension of a configured agent i
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.
4. Aggregate of AI contexts 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
Expand Down Expand Up @@ -235,6 +235,12 @@ tools = ["get_date"]

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

In addition to configured sections, the `use` property can also reference exported contexts as either `AIContext`
(for static context) or `AIContextProvider` (for dynamic context) registered in DI with a matching name.


### Extensible Tools

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

```csharp
Expand Down
64 changes: 54 additions & 10 deletions src/Agents/CompositeAIContextProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,69 @@ namespace Devlooped.Agents.AI;
/// </summary>
class CompositeAIContextProvider : AIContextProvider
{
readonly AIContext context;
readonly IList<AIContextProvider> providers;
readonly AIContext? staticContext;

public CompositeAIContextProvider(IList<AIContext> contexts)
public CompositeAIContextProvider(IList<AIContextProvider> providers)
{
if (contexts.Count == 1)
this.providers = providers;

// Special case for single provider of static contexts
if (providers.Count == 1 && providers[0] is StaticAIContextProvider staticProvider)
{
context = contexts[0];
staticContext = staticProvider.Context;
return;
}

// Concatenate instructions from all contexts
context = new();
// Special case where all providers are static
if (providers.All(x => x is StaticAIContextProvider))
{
// Concatenate instructions from all contexts
staticContext = new();
var instructions = new List<string>();
var messages = new List<ChatMessage>();
var tools = new List<AITool>();

foreach (var provider in providers.Cast<StaticAIContextProvider>())
{
var ctx = provider.Context;

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)
staticContext.Instructions = string.Join('\n', instructions);

if (messages.Count > 0)
staticContext.Messages = messages;

if (tools.Count > 0)
staticContext.Tools = tools;
}
}

public override async ValueTask<AIContext> InvokingAsync(InvokingContext invoking, CancellationToken cancellationToken = default)
{
if (staticContext is not null)
return staticContext;

var context = new AIContext();
var instructions = new List<string>();
var messages = new List<ChatMessage>();
var tools = new List<AITool>();

foreach (var ctx in contexts)
foreach (var provider in providers)
{
var ctx = await provider.InvokingAsync(invoking, cancellationToken);

if (!string.IsNullOrEmpty(ctx.Instructions))
instructions.Add(ctx.Instructions);

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

if (tools.Count > 0)
context.Tools = tools;
}

public override ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(this.context);
return context;
}
}
17 changes: 11 additions & 6 deletions src/Agents/ConfigurableAIAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,16 +148,21 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum
}
else if (options.Use?.Count > 0 || options.Tools?.Count > 0)
{
var contexts = new List<AIContext>();
var contexts = new List<AIContextProvider>();
foreach (var use in options.Use ?? [])
{
var context = services.GetKeyedService<AIContext>(use);
if (context is not null)
if (services.GetKeyedService<AIContext>(use) is { } staticContext)
{
contexts.Add(context);
contexts.Add(new StaticAIContextProvider(staticContext));
continue;
}
else if (services.GetKeyedService<AIContextProvider>(use) is { } dynamicContext)
{
contexts.Add(dynamicContext);
continue;
}

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

contexts.Add(configured);
contexts.Add(new StaticAIContextProvider(configured));
continue;
}

Expand All @@ -193,7 +198,7 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum
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] });
contexts.Add(new StaticAIContextProvider(new AIContext { Tools = [tool] }));
}

options.AIContextProviderFactory = _ => new CompositeAIContextProvider(contexts);
Expand Down
11 changes: 11 additions & 0 deletions src/Agents/StaticAIContextProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Microsoft.Agents.AI;

namespace Devlooped.Agents.AI;

class StaticAIContextProvider(AIContext context) : AIContextProvider
{
public AIContext Context => context;

public override ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(Context);
}
118 changes: 118 additions & 0 deletions src/Tests/ConfigurableAgentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,124 @@ public async Task UseAIContextFromSection()
Assert.Same(tool, context.Tools?.First());
}

[Fact]
public async Task UseAIContextFromProvider()
{
var builder = new HostApplicationBuilder();
var voseo =
"""
Default to using spanish language, using argentinean "voseo" in your responses.
""";

builder.Configuration.AddToml(
$$"""
[ai.clients.openai]
modelid = "gpt-4.1"
apikey = "sk-asdf"

[ai.agents.chat]
description = "Chat agent."
client = "openai"
use = ["default"]
""");

var tool = AIFunctionFactory.Create(() => DateTimeOffset.Now, "get_date");
builder.Services.AddKeyedSingleton("default", Mock.Of<AIContextProvider>(x
=> x.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), default) == ValueTask.FromResult(new AIContext
{
Instructions = voseo,
Tools = new[] { tool }
})));

builder.AddAIAgents();
var app = builder.Build();

var agent = app.Services.GetRequiredKeyedService<AIAgent>("chat");
var options = agent.GetService<ChatClientAgentOptions>();

Assert.NotNull(options?.AIContextProviderFactory);
var provider = options?.AIContextProviderFactory?.Invoke(new());
Assert.NotNull(provider);

var context = await provider.InvokingAsync(new([]), default);

Assert.NotNull(context.Instructions);
Assert.Equal(voseo, context.Instructions);
Assert.Same(tool, context.Tools?.First());
}

[Fact]
public async Task CombineAIContextFromStaticDinamicAndSection()
{
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 = ["default", "static", "dynamic"]

[ai.context.default]
instructions = 'foo'
messages = [
{ system = "You are strictly professional." },
{ user = "Hey you!"},
{ assistant = "Hello there. How can I assist you today?" }
]
tools = ["get_date"]
""");

var tool = AIFunctionFactory.Create(() => DateTimeOffset.Now, "get_date");
builder.Services.AddKeyedSingleton("get_date", tool);

builder.Services.AddKeyedSingleton("static", new AIContext
{
Instructions = "bar",
Tools = new AITool[] { AIFunctionFactory.Create(() => "bar", "get_bar") }
});

AITool[] getbaz = [AIFunctionFactory.Create(() => "baz", "get_baz")];

builder.Services.AddKeyedSingleton("dynamic", Mock.Of<AIContextProvider>(x
=> x.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), default) == ValueTask.FromResult(new AIContext
{
Instructions = "baz",
Tools = getbaz
})));

builder.AddAIAgents();
var app = builder.Build();

var agent = app.Services.GetRequiredKeyedService<AIAgent>("chat");
var options = agent.GetService<ChatClientAgentOptions>();

Assert.NotNull(options?.AIContextProviderFactory);
var provider = options?.AIContextProviderFactory?.Invoke(new());
Assert.NotNull(provider);

var context = await provider.InvokingAsync(new([]), default);

Assert.NotNull(context.Instructions);
Assert.Contains("foo", context.Instructions);
Assert.Contains("bar", context.Instructions);
Assert.Contains("baz", context.Instructions);

Assert.Equal(3, context.Messages?.Count);
Assert.Single(context.Messages!, x => x.Role == ChatRole.System && x.Text == "You are strictly professional.");
Assert.Single(context.Messages!, x => x.Role == ChatRole.User && x.Text == "Hey you!");
Assert.Single(context.Messages!, x => x.Role == ChatRole.Assistant && x.Text == "Hello there. How can I assist you today?");

Assert.NotNull(context.Tools);
Assert.Contains(tool, context.Tools!);
Assert.Contains(context.Tools, x => x.Name == "get_bar");
Assert.Contains(context.Tools, x => x.Name == "get_baz");
}

[Fact]
public async Task MissingToolAIContextFromSectionThrows()
{
Expand Down
Loading