Merge branch 'main' into crickman/workflows-declarative-nocodegen

This commit is contained in:
Chris
2026-03-25 14:32:13 -07:00
committed by GitHub
Unverified
7 changed files with 183 additions and 19 deletions
@@ -38,6 +38,7 @@ internal static class SourceBuilder
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine("using Microsoft.Agents.AI.Workflows;");
sb.AppendLine();
sb.AppendLine("using RouteBuilder = Microsoft.Agents.AI.Workflows.RouteBuilder;");
// Namespace
if (!string.IsNullOrWhiteSpace(info.Namespace))
@@ -3,9 +3,9 @@
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Provides extensions methods for creating <see cref="Configured{TSubject}"/> objects
/// Provides extension methods for creating <see cref="Configured{TSubject}"/> objects
/// </summary>
public static class ConfigurationExtensions
internal static class ConfigurationExtensions
{
/// <summary>
/// Creates a new configuration that treats the subject as its base type, allowing configuration to be applied at
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Provides methods for creating <see cref="Configured{TSubject}"/> instances.
/// </summary>
public static class Configured
internal static class Configured
{
/// <summary>
/// Creates a <see cref="Configured{TSubject}"/> instance from an existing subject instance.
@@ -50,10 +50,10 @@ public static class Configured
/// A representation of a preconfigured, lazy-instantiatable instance of <typeparamref name="TSubject"/>.
/// </summary>
/// <typeparam name="TSubject">The type of the preconfigured subject.</typeparam>
/// <param name="factoryAsync">A factory to intantiate the subject when desired.</param>
/// <param name="factoryAsync">A factory to instantiate the subject when desired.</param>
/// <param name="id">The unique identifier for the configured subject.</param>
/// <param name="raw"></param>
public class Configured<TSubject>(Func<Config, string, ValueTask<TSubject>> factoryAsync, string id, object? raw = null)
internal class Configured<TSubject>(Func<ExecutorConfig, string, ValueTask<TSubject>> factoryAsync, string id, object? raw = null)
{
/// <summary>
/// Gets the raw representation of the configured object, if any.
@@ -66,14 +66,14 @@ public class Configured<TSubject>(Func<Config, string, ValueTask<TSubject>> fact
public string Id => id;
/// <summary>
/// Gets the factory function to create an instance of <typeparamref name="TSubject"/> given a <see cref="Config"/>.
/// Gets the factory function to create an instance of <typeparamref name="TSubject"/> given a <see cref="ExecutorConfig"/>.
/// </summary>
public Func<Config, string, ValueTask<TSubject>> FactoryAsync => factoryAsync;
public Func<ExecutorConfig, string, ValueTask<TSubject>> FactoryAsync => factoryAsync;
/// <summary>
/// The configuration for this configured instance.
/// </summary>
public Config Configuration => new(this.Id);
public ExecutorConfig Configuration => new(this.Id);
/// <summary>
/// Gets a "partially" applied factory function that only requires no parameters to create an instance of
@@ -87,11 +87,11 @@ public class Configured<TSubject>(Func<Config, string, ValueTask<TSubject>> fact
/// </summary>
/// <typeparam name="TSubject">The type of the preconfigured subject.</typeparam>
/// <typeparam name="TOptions">The type of configuration options for the preconfigured subject.</typeparam>
/// <param name="factoryAsync">A factory to intantiate the subject when desired.</param>
/// <param name="factoryAsync">A factory to instantiate the subject when desired.</param>
/// <param name="id">The unique identifier for the configured subject.</param>
/// <param name="options">Additional configuration options for the subject.</param>
/// <param name="raw"></param>
public class Configured<TSubject, TOptions>(Func<Config<TOptions>, string, ValueTask<TSubject>> factoryAsync, string id, TOptions? options = default, object? raw = null)
internal class Configured<TSubject, TOptions>(Func<ExecutorConfig<TOptions>, string, ValueTask<TSubject>> factoryAsync, string id, TOptions? options = default, object? raw = null)
{
/// <summary>
/// The raw representation of the configured object, if any.
@@ -109,14 +109,14 @@ public class Configured<TSubject, TOptions>(Func<Config<TOptions>, string, Value
public TOptions? Options => options;
/// <summary>
/// Gets the factory function to create an instance of <typeparamref name="TSubject"/> given a <see cref="Config{TOptions}"/>.
/// Gets the factory function to create an instance of <typeparamref name="TSubject"/> given a <see cref="ExecutorConfig{TOptions}"/>.
/// </summary>
public Func<Config<TOptions>, string, ValueTask<TSubject>> FactoryAsync => factoryAsync;
public Func<ExecutorConfig<TOptions>, string, ValueTask<TSubject>> FactoryAsync => factoryAsync;
/// <summary>
/// The configuration for this configured instance.
/// </summary>
public Config<TOptions> Configuration => new(this.Id, this.Options);
public ExecutorConfig<TOptions> Configuration => new(this.Id, this.Options);
/// <summary>
/// Gets a "partially" applied factory function that only requires no parameters to create an instance of
@@ -124,11 +124,11 @@ public class Configured<TSubject, TOptions>(Func<Config<TOptions>, string, Value
/// </summary>
internal Func<string, ValueTask<TSubject>> BoundFactoryAsync => (sessionId) => this.CreateValidatingMemoizedFactory()(this.Configuration, sessionId);
private Func<Config, string, ValueTask<TSubject>> CreateValidatingMemoizedFactory()
private Func<ExecutorConfig, string, ValueTask<TSubject>> CreateValidatingMemoizedFactory()
{
return FactoryAsync;
async ValueTask<TSubject> FactoryAsync(Config configuration, string sessionId)
async ValueTask<TSubject> FactoryAsync(ExecutorConfig configuration, string sessionId)
{
if (this.Id != configuration.Id)
{
@@ -113,7 +113,7 @@ public static class ExecutorBindingExtensions
/// <param name="id">An id for the executor to be instantiated.</param>
/// <param name="options">An optional parameter specifying the options.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorBinding BindExecutor<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
public static ExecutorBinding BindExecutor<TExecutor, TOptions>(this Func<ExecutorConfig<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
where TExecutor : Executor
where TOptions : ExecutorOptions
{
@@ -139,7 +139,7 @@ public static class ExecutorBindingExtensions
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
[Obsolete("Use BindExecutor() instead")]
[EditorBrowsable(EditorBrowsableState.Never)]
public static ExecutorBinding ConfigureFactory<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
public static ExecutorBinding ConfigureFactory<TExecutor, TOptions>(this Func<ExecutorConfig<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
where TExecutor : Executor
where TOptions : ExecutorOptions
=> factoryAsync.BindExecutor(id, options);
@@ -6,7 +6,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// Represents a configuration for an object with a string identifier. For example, <see cref="IIdentified"/> object.
/// </summary>
/// <param name="id">A unique identifier for the configurable object.</param>
public class Config(string id)
public class ExecutorConfig(string id)
{
/// <summary>
/// Gets a unique identifier for the configurable object.
@@ -23,7 +23,7 @@ public class Config(string id)
/// <typeparam name="TOptions">The type of options for the configurable object.</typeparam>
/// <param name="id">A unique identifier for the configurable object.</param>
/// <param name="options">The options for the configurable object.</param>
public class Config<TOptions>(string id, TOptions? options = default) : Config(id)
public class ExecutorConfig<TOptions>(string id, TOptions? options = default) : ExecutorConfig(id)
{
/// <summary>
/// Gets the options for the configured object.
@@ -161,6 +161,8 @@ internal sealed class AIContextProviderChatClient : DelegatingChatClient
}
// Materialize the accumulated context back into messages and options.
// Clone options to avoid mutating the caller's instance across calls.
options = options?.Clone();
var enrichedMessages = aiContext.Messages ?? [];
var tools = aiContext.Tools as IList<AITool> ?? aiContext.Tools?.ToList();
@@ -250,6 +250,129 @@ public class AIContextProviderChatClientTests
#endregion
#region Shared Options Tests
[Fact]
public async Task GetResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync()
{
// Arrange: track tool count seen by the inner client on each call
var toolCountsSeenByInner = new List<int>();
var innerClient = CreateMockChatClient(
onGetResponse: (_, options, _) =>
{
toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0);
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
});
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
var sharedOptions = new ChatOptions
{
Tools = new List<AITool> { new TestAITool() }
};
// Act: make 3 calls reusing the same ChatOptions
for (int i = 0; i < 3; i++)
{
await RunWithAgentContextAsync(chatClient, sharedOptions);
}
// Assert: each call should see exactly 2 tools (1 baseline + 1 injected)
Assert.Equal(3, toolCountsSeenByInner.Count);
Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count));
}
[Fact]
public async Task GetResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync()
{
// Arrange
var innerClient = CreateMockChatClient(
onGetResponse: (_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")])));
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
var baselineTool = new TestAITool();
var originalTools = new List<AITool> { baselineTool };
var sharedOptions = new ChatOptions
{
Tools = originalTools
};
// Act
await RunWithAgentContextAsync(chatClient, sharedOptions);
// Assert: the original list should still contain only the baseline tool
Assert.Single(originalTools);
Assert.Same(baselineTool, originalTools[0]);
Assert.Same(originalTools, sharedOptions.Tools);
Assert.Same(baselineTool, originalTools[0]);
}
[Fact]
public async Task GetStreamingResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync()
{
// Arrange
var toolCountsSeenByInner = new List<int>();
var innerClient = CreateMockStreamingChatClient(
onGetStreamingResponse: (_, options, _) =>
{
toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0);
return ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "Response"));
});
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
var sharedOptions = new ChatOptions
{
Tools = new List<AITool> { new TestAITool() }
};
// Act: make 3 streaming calls reusing the same ChatOptions
for (int i = 0; i < 3; i++)
{
await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions);
}
// Assert: each call should see exactly 2 tools (1 baseline + 1 injected)
Assert.Equal(3, toolCountsSeenByInner.Count);
Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count));
}
[Fact]
public async Task GetStreamingResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync()
{
// Arrange
var innerClient = CreateMockStreamingChatClient(
onGetStreamingResponse: (_, _, _) => ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "Response")));
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
var baselineTool = new TestAITool();
var originalTools = new List<AITool> { baselineTool };
var sharedOptions = new ChatOptions
{
Tools = originalTools
};
// Act
await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions);
// Assert: the original list should still contain only the baseline tool
Assert.Single(originalTools);
Assert.Same(baselineTool, originalTools[0]);
}
#endregion
#region Builder Extension Tests
[Fact]
@@ -341,6 +464,44 @@ public class AIContextProviderChatClientTests
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
}
/// <summary>
/// Runs a chat client within an agent context with the specified options.
/// </summary>
private static async Task RunWithAgentContextAsync(AIContextProviderChatClient chatClient, ChatOptions options)
{
var agent = new TestAIAgent
{
RunAsyncFunc = async (messages, session, agentOptions, ct) =>
{
var response = await chatClient.GetResponseAsync(messages, options, ct);
return new AgentResponse(response);
}
};
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
}
/// <summary>
/// Runs a streaming chat client within an agent context with the specified options.
/// </summary>
private static async Task RunStreamingWithAgentContextAsync(AIContextProviderChatClient chatClient, List<ChatResponseUpdate> updates, ChatOptions options)
{
var agent = new TestAIAgent
{
RunAsyncFunc = async (messages, session, agentOptions, ct) =>
{
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options, ct))
{
updates.Add(update);
}
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]);
}
};
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
}
private static IChatClient CreateMockChatClient(
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, Task<ChatResponse>> onGetResponse)
{