diff --git a/readme.md b/readme.md index 8932132..c9a6fc7 100644 --- a/readme.md +++ b/readme.md @@ -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 @@ -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 diff --git a/src/Agents/CompositeAIContextProvider.cs b/src/Agents/CompositeAIContextProvider.cs index ca0d181..9597c11 100644 --- a/src/Agents/CompositeAIContextProvider.cs +++ b/src/Agents/CompositeAIContextProvider.cs @@ -8,24 +8,69 @@ namespace Devlooped.Agents.AI; /// class CompositeAIContextProvider : AIContextProvider { - readonly AIContext context; + readonly IList providers; + readonly AIContext? staticContext; - public CompositeAIContextProvider(IList contexts) + public CompositeAIContextProvider(IList 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(); + var messages = new List(); + var tools = new List(); + + foreach (var provider in providers.Cast()) + { + 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 InvokingAsync(InvokingContext invoking, CancellationToken cancellationToken = default) + { + if (staticContext is not null) + return staticContext; + + var context = new AIContext(); var instructions = new List(); var messages = new List(); var tools = new List(); - 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); @@ -45,8 +90,7 @@ public CompositeAIContextProvider(IList contexts) if (tools.Count > 0) context.Tools = tools; - } - public override ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) - => ValueTask.FromResult(this.context); + return context; + } } \ No newline at end of file diff --git a/src/Agents/ConfigurableAIAgent.cs b/src/Agents/ConfigurableAIAgent.cs index 0a69189..eb71930 100644 --- a/src/Agents/ConfigurableAIAgent.cs +++ b/src/Agents/ConfigurableAIAgent.cs @@ -148,16 +148,21 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum } else if (options.Use?.Count > 0 || options.Tools?.Count > 0) { - var contexts = new List(); + var contexts = new List(); foreach (var use in options.Use ?? []) { - var context = services.GetKeyedService(use); - if (context is not null) + if (services.GetKeyedService(use) is { } staticContext) { - contexts.Add(context); + contexts.Add(new StaticAIContextProvider(staticContext)); + continue; + } + else if (services.GetKeyedService(use) is { } dynamicContext) + { + contexts.Add(dynamicContext); continue; } + // Else, look for a config section. if (configuration.GetSection("ai:context:" + use) is { } ctxSection && ctxSection.Get() is { } ctxConfig) { @@ -180,7 +185,7 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum } } - contexts.Add(configured); + contexts.Add(new StaticAIContextProvider(configured)); continue; } @@ -193,7 +198,7 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum services.GetKeyedService(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); diff --git a/src/Agents/StaticAIContextProvider.cs b/src/Agents/StaticAIContextProvider.cs new file mode 100644 index 0000000..4c75e2b --- /dev/null +++ b/src/Agents/StaticAIContextProvider.cs @@ -0,0 +1,11 @@ +using Microsoft.Agents.AI; + +namespace Devlooped.Agents.AI; + +class StaticAIContextProvider(AIContext context) : AIContextProvider +{ + public AIContext Context => context; + + public override ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) + => ValueTask.FromResult(Context); +} diff --git a/src/Tests/ConfigurableAgentTests.cs b/src/Tests/ConfigurableAgentTests.cs index ae31366..7d0edd0 100644 --- a/src/Tests/ConfigurableAgentTests.cs +++ b/src/Tests/ConfigurableAgentTests.cs @@ -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(x + => x.InvokingAsync(It.IsAny(), default) == ValueTask.FromResult(new AIContext + { + Instructions = voseo, + Tools = new[] { 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.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(x + => x.InvokingAsync(It.IsAny(), default) == ValueTask.FromResult(new AIContext + { + Instructions = "baz", + Tools = getbaz + }))); + + 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.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() {