.NET: [BREAKING] feat: Improve Agent hosting inside Workflows (#3142)

* refactor: Rename AggregateTurnMessagesExecutor

* feat: Rework Agent Hosting for Configurability and HIL support

* Adds support for selecting whether updates and/or full responses are
  emitted to events
* Adds support for HIL/FunctionCalls (including interception)
* Implements internal support for ExternalRequests from any executor
  (not just RequestPort)

* test: Add tests for new AIAgentHostExecutor functionality

* feat: Unify non-Handoff Agent Hosting

* doc: More explicit documentation for `overwrite` in RouteBuilder
This commit is contained in:
Jacob Alber
2026-01-23 14:45:29 -05:00
committed by GitHub
Unverified
parent 21e00c054b
commit 6e8c7c42c8
37 changed files with 1883 additions and 309 deletions
@@ -6,16 +6,27 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents the workflow binding details for an AI agent, including configuration options for event emission.
/// Represents the workflow binding details for an AI agent, including configuration options for agent hosting behaviour.
/// </summary>
/// <param name="Agent">The AI agent.</param>
/// <param name="EmitEvents">Specifies whether the agent should emit events. If null, the default behavior is applied.</param>
public record AIAgentBinding(AIAgent Agent, bool EmitEvents = false)
/// <param name="Options">The options for configuring the AI agent host.
/// </param>
public record AIAgentBinding(AIAgent Agent, AIAgentHostOptions? Options = null)
: ExecutorBinding(Throw.IfNull(Agent).GetDescriptiveId(),
(_) => new(new AIAgentHostExecutor(Agent, EmitEvents)),
(_) => new(new AIAgentHostExecutor(Agent, Options ?? new())),
typeof(AIAgentHostExecutor),
Agent)
{
/// <summary>
/// Initializes a new instance of the AIAgentBinding class, associating it with the specified AI agent and
/// optionally enabling event emission.
/// </summary>
/// <param name="agent">The AI agent.</param>
/// <param name="emitEvents">Specifies whether the agent should emit events. If null, the default behavior is applied.</param>
public AIAgentBinding(AIAgent agent, bool emitEvents = false)
: this(agent, new AIAgentHostOptions { EmitAgentUpdateEvents = emitEvents })
{ }
/// <inheritdoc/>
public override bool IsSharedInstance => false;
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Configuration options hosting AI Agents as an Executor.
/// </summary>
public sealed class AIAgentHostOptions
{
/// <summary>
/// Gets or sets a value indicating whether agent streaming update events should be emitted during execution.
/// If <see langword="null"/>, the value will be taken from the <see cref="TurnToken"/>
/// </summary>
public bool? EmitAgentUpdateEvents { get; set; }
/// <summary>
/// Gets or sets a value indicating whether aggregated agent response events should be emitted during execution.
/// </summary>
public bool EmitAgentResponseEvents { get; set; }
/// <summary>
/// Gets or sets a value indicating whether <see cref="UserInputRequestContent"/> should be intercepted and sent
/// as a message to the workflow for handling, instead of being raised as a request.
/// </summary>
public bool InterceptUserInputRequests { get; set; }
/// <summary>
/// Gets or sets a value indicating whether <see cref="FunctionCallContent"/> without a corresponding
/// <see cref="FunctionResultContent"/> should be intercepted and sent as a message to the workflow for handling,
/// instead of being raised as a request.
/// </summary>
public bool InterceptUnterminatedFunctionCalls { get; set; }
/// <summary>
/// Gets or sets a value indicating whether other messages from other agents should be assigned to the
/// <see cref="ChatRole.User"/> role during execution.
/// </summary>
public bool ReassignOtherAgentsAsUsers { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether incoming messages are automatically forwarded before new messages generated
/// by the agent during its turn.
/// </summary>
public bool ForwardIncomingMessages { get; set; } = true;
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
@@ -19,6 +20,29 @@ internal static class AIAgentsAbstractionsExtensions
RawRepresentation = update.RawRepresentation ?? update,
};
public static ChatMessage ChatAssistantToUserIfNotFromNamed(this ChatMessage message, string agentName)
=> message.ChatAssistantToUserIfNotFromNamed(agentName, out _, false);
private static ChatMessage ChatAssistantToUserIfNotFromNamed(this ChatMessage message, string agentName, out bool changed, bool inplace = true)
{
changed = false;
if (message.Role == ChatRole.Assistant &&
!StringComparer.Ordinal.Equals(message.AuthorName, agentName) &&
message.Contents.All(c => c is TextContent or DataContent or UriContent or UsageContent))
{
if (!inplace)
{
message = message.Clone();
}
message.Role = ChatRole.User;
changed = true;
}
return message;
}
/// <summary>
/// Iterates through <paramref name="messages"/> looking for <see cref="ChatRole.Assistant"/> messages and swapping
/// any that have a different <see cref="ChatMessage.AuthorName"/> from <paramref name="targetAgentName"/> to
@@ -29,11 +53,9 @@ internal static class AIAgentsAbstractionsExtensions
List<ChatMessage>? roleChanged = null;
foreach (var m in messages)
{
if (m.Role == ChatRole.Assistant &&
m.AuthorName != targetAgentName &&
m.Contents.All(c => c is TextContent or DataContent or UriContent or UsageContent))
m.ChatAssistantToUserIfNotFromNamed(targetAgentName, out bool changed);
if (changed)
{
m.Role = ChatRole.User;
(roleChanged ??= []).Add(m);
}
}
@@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
@@ -35,38 +34,28 @@ public static partial class AgentWorkflowBuilder
private static Workflow BuildSequentialCore(string? workflowName, params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
Throw.IfNullOrEmpty(agents);
// Create a builder that chains the agents together in sequence. The workflow simply begins
// with the first agent in the sequence.
WorkflowBuilder? builder = null;
ExecutorBinding? previous = null;
foreach (var agent in agents)
AIAgentHostOptions options = new()
{
AgentRunStreamingExecutor agentExecutor = new(agent, includeInputInOutput: true);
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = true,
};
if (builder is null)
{
builder = new WorkflowBuilder(agentExecutor);
}
else
{
Debug.Assert(previous is not null);
builder.AddEdge(previous, agentExecutor);
}
List<ExecutorBinding> agentExecutors = agents.Select(agent => agent.BindAsExecutor(options)).ToList();
previous = agentExecutor;
}
ExecutorBinding previous = agentExecutors[0];
WorkflowBuilder builder = new(previous);
if (previous is null)
foreach (ExecutorBinding next in agentExecutors.Skip(1))
{
Throw.ArgumentException(nameof(agents), "At least one agent must be provided to build a sequential workflow.");
builder.AddEdge(previous, next);
previous = next;
}
// Add an ending executor that batches up all messages from the last agent
// so that it's published as a single list result.
Debug.Assert(builder is not null);
OutputMessagesExecutor end = new();
builder = builder.AddEdge(previous, end).WithOutputFrom(end);
if (workflowName is not null)
@@ -125,9 +114,12 @@ public static partial class AgentWorkflowBuilder
// so that the final accumulator receives a single list of messages from each agent. Otherwise, the
// accumulator would not be able to determine what came from what agent, as there's currently no
// provenance tracking exposed in the workflow context passed to a handler.
ExecutorBinding[] agentExecutors = (from agent in agents select (ExecutorBinding)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray();
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")];
ExecutorBinding[] agentExecutors = (from agent in agents
select agent.BindAsExecutor(new AIAgentHostOptions() { ReassignOtherAgentsAsUsers = true })).ToArray();
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new AggregateTurnMessagesExecutor($"Batcher/{agent.Id}")];
builder.AddFanOutEdge(start, agentExecutors);
for (int i = 0; i < agentExecutors.Length; i++)
{
builder.AddEdge(agentExecutors[i], accumulators[i]);
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -18,6 +19,12 @@ public class ChatProtocolExecutorOptions
/// If set, the executor will accept string messages and convert them to chat messages with this role.
/// </summary>
public ChatRole? StringMessageChatRole { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the executor should automatically send the <see cref="TurnToken"/>
/// after returning from <see cref="ChatProtocolExecutor.TakeTurnAsync(List{ChatMessage}, IWorkflowContext, bool?, CancellationToken)"/>
/// </summary>
public bool AutoSendTurnToken { get; set; } = true;
}
/// <summary>
@@ -26,8 +33,8 @@ public class ChatProtocolExecutorOptions
/// </summary>
public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
{
private static readonly Func<List<ChatMessage>> s_initFunction = () => [];
private readonly ChatRole? _stringMessageChatRole;
internal static readonly Func<List<ChatMessage>> s_initFunction = () => [];
private readonly ChatProtocolExecutorOptions _options;
private static readonly StatefulExecutorOptions s_baseExecutorOptions = new()
{
@@ -44,16 +51,28 @@ public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
protected ChatProtocolExecutor(string id, ChatProtocolExecutorOptions? options = null, bool declareCrossRunShareable = false)
: base(id, () => [], s_baseExecutorOptions, declareCrossRunShareable)
{
this._stringMessageChatRole = options?.StringMessageChatRole;
this._options = options ?? new();
}
/// <summary>
/// Gets a value indicating whether string-based messages are supported by this <see cref="ChatProtocolExecutor"/>.
/// </summary>
[MemberNotNullWhen(true, nameof(StringMessageChatRole))]
protected bool SupportsStringMessage => this.StringMessageChatRole.HasValue;
/// <inheritdoc cref="ChatProtocolExecutorOptions.StringMessageChatRole"/>
protected ChatRole? StringMessageChatRole => this._options.StringMessageChatRole;
/// <inheritdoc cref="ChatProtocolExecutorOptions.AutoSendTurnToken"/>
protected bool AutoSendTurnToken => this._options.AutoSendTurnToken;
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
if (this._stringMessageChatRole.HasValue)
if (this.SupportsStringMessage)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => this.AddMessageAsync(new(this._stringMessageChatRole.Value, message), context));
(message, context) => this.AddMessageAsync(new(this.StringMessageChatRole.Value, message), context));
}
return routeBuilder.AddHandler<ChatMessage>(this.AddMessageAsync)
@@ -117,7 +136,10 @@ public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
await this.TakeTurnAsync(maybePendingMessages ?? s_initFunction(), context, token.EmitEvents, cancellationToken)
.ConfigureAwait(false);
await context.SendMessageAsync(token, cancellationToken: cancellationToken).ConfigureAwait(false);
if (this.AutoSendTurnToken)
{
await context.SendMessageAsync(token, cancellationToken: cancellationToken).ConfigureAwait(false);
}
// Rerun the initialStateFactory to reset the state to empty list. (We could return the empty list directly,
// but this is more consistent if the initial state factory becomes more complex.)
@@ -125,6 +147,28 @@ public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
}
}
/// <summary>
/// Processes the current set of turn messages using the specified asynchronous processing function.
/// </summary>
/// <remarks>If the provided list of chat messages is null, an initial empty list is supplied to the
/// processing function. If the processing function returns null, an empty list is used as the result.</remarks>
/// <param name="processFunc">A delegate that asynchronously processes a list of chat messages within the given workflow context and
/// cancellation token, returning the processed list of chat messages or null.</param>
/// <param name="context">The workflow context in which the messages are processed.</param>
/// <param name="cancellationToken">A token that can be used to cancel the asynchronous operation.</param>
/// <returns>A ValueTask that represents the asynchronous operation. The result contains the processed list of chat messages,
/// or an empty list if the processing function returns null.</returns>
protected ValueTask ProcessTurnMessagesAsync(Func<List<ChatMessage>, IWorkflowContext, CancellationToken, ValueTask<List<ChatMessage>?>> processFunc, IWorkflowContext context, CancellationToken cancellationToken)
{
return this.InvokeWithStateAsync(InvokeProcessFuncAsync, context, cancellationToken: cancellationToken);
async ValueTask<List<ChatMessage>?> InvokeProcessFuncAsync(List<ChatMessage>? maybePendingMessages, IWorkflowContext context, CancellationToken cancellationToken)
{
return (await processFunc(maybePendingMessages ?? s_initFunction(), context, cancellationToken).ConfigureAwait(false))
?? s_initFunction();
}
}
/// <summary>
/// When overridden in a derived class, processes the accumulated chat messages for a single turn.
/// </summary>
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@@ -12,7 +13,7 @@ internal sealed class EdgeMap
{
private readonly Dictionary<EdgeId, EdgeRunner> _edgeRunners = [];
private readonly Dictionary<EdgeId, IStatefulEdgeRunner> _statefulRunners = [];
private readonly Dictionary<string, ResponseEdgeRunner> _portEdgeRunners;
private readonly ConcurrentDictionary<string, ResponseEdgeRunner> _portEdgeRunners;
private readonly ResponseEdgeRunner _inputRunner;
private readonly IStepTracer? _stepTracer;
@@ -51,12 +52,16 @@ internal sealed class EdgeMap
}
}
this._portEdgeRunners = workflowPorts.ToDictionary(
port => port.Id,
port => ResponseEdgeRunner.ForPort(runContext, port)
);
this._portEdgeRunners = new();
foreach (RequestPort port in workflowPorts)
{
if (!this.TryRegisterPort(runContext, port.Id, port))
{
throw new InvalidOperationException($"Duplicate port ID detected: {port.Id}");
}
}
this._inputRunner = new ResponseEdgeRunner(runContext, startExecutorId);
this._inputRunner = new ResponseEdgeRunner(runContext, startExecutorId, "");
this._stepTracer = stepTracer;
}
@@ -71,6 +76,9 @@ internal sealed class EdgeMap
return edgeRunner.ChaseEdgeAsync(message, this._stepTracer);
}
public bool TryRegisterPort(IRunnerContext runContext, string executorId, RequestPort port)
=> this._portEdgeRunners.TryAdd(port.Id, ResponseEdgeRunner.ForPort(runContext, executorId, port));
public ValueTask<DeliveryMapping?> PrepareDeliveryForInputAsync(MessageEnvelope message)
{
return this._inputRunner.ChaseEdgeAsync(message, this._stepTracer);
@@ -12,6 +12,6 @@ internal interface IRunnerContext : IExternalRequestSink, ISuperStepJoinContext
ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null, CancellationToken cancellationToken = default);
ValueTask<StepContext> AdvanceAsync(CancellationToken cancellationToken = default);
IWorkflowContext Bind(string executorId, Dictionary<string, string>? traceContext = null);
IWorkflowContext BindWorkflowContext(string executorId, Dictionary<string, string>? traceContext = null);
ValueTask<Executor> EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken = default);
}
@@ -8,17 +8,19 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string sinkId)
internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string executorId, string sinkId)
: EdgeRunner<string>(runContext, sinkId)
{
public static ResponseEdgeRunner ForPort(IRunnerContext runContext, RequestPort port)
public static ResponseEdgeRunner ForPort(IRunnerContext runContext, string executorId, RequestPort port)
{
Throw.IfNull(port);
// The port is an request port, so we can use the port's ID as the sink ID.
return new ResponseEdgeRunner(runContext, port.Id);
return new ResponseEdgeRunner(runContext, executorId, port.Id);
}
public string ExecutorId => executorId;
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
{
Debug.Assert(envelope.IsExternal, "Input edges should only be chased from external input");
@@ -27,7 +29,7 @@ internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string sinkI
activity?
.SetTag(Tags.EdgeGroupType, nameof(ResponseEdgeRunner))
.SetTag(Tags.MessageSourceId, envelope.SourceId)
.SetTag(Tags.MessageTargetId, this.EdgeData);
.SetTag(Tags.MessageTargetId, $"{this.ExecutorId}[{this.EdgeData}]");
try
{
@@ -48,5 +50,5 @@ internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string sinkI
}
}
private async ValueTask<Executor> FindExecutorAsync(IStepTracer? tracer) => await this.RunContext.EnsureExecutorAsync(this.EdgeData, tracer).ConfigureAwait(false);
private async ValueTask<Executor> FindExecutorAsync(IStepTracer? tracer) => await this.RunContext.EnsureExecutorAsync(this.ExecutorId, tracer).ConfigureAwait(false);
}
@@ -16,7 +16,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// A component that processes messages in a <see cref="Workflow"/>.
/// </summary>
[DebuggerDisplay("{GetType().Name}{Id}")]
[DebuggerDisplay("{GetType().Name}[{Id}]")]
public abstract class Executor : IIdentified
{
/// <summary>
@@ -63,6 +63,24 @@ public abstract class Executor : IIdentified
/// </summary>
protected abstract RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder);
internal void Configure(IExternalRequestContext externalRequestContext)
{
// TODO: This is an unfortunate pattern (pending the ability to rework the Configure APIs a bit):
// new()
// >>> will throw InvalidOperationException if Configure() is not invoked when using PortHandlers
// .Configure()
// >>> only usable now
// The fix would be to change the API surface of Executor to have Configure return the contract that the workflow
// will use to invoke the executor (currently the MessageRouter). (Ideally we would rename Executor to Node or similar,
// and the actual Executor class will represent that Contract object)
// Not a terrible issue right now because only InProcessExecution exists right now, and the InProccessRunContext centralizes
// executor instantiation in EnsureExecutorAsync.
this.Router = this.CreateRouter(externalRequestContext);
}
private MessageRouter CreateRouter(IExternalRequestContext? externalRequestContext = null)
=> this.ConfigureRoutes(new RouteBuilder(externalRequestContext)).Build();
/// <summary>
/// Perform any asynchronous initialization required by the executor. This method is called once per executor instance,
/// </summary>
@@ -99,12 +117,15 @@ public abstract class Executor : IIdentified
{
if (field is null)
{
RouteBuilder routeBuilder = this.ConfigureRoutes(new RouteBuilder());
field = routeBuilder.Build();
field = this.CreateRouter();
}
return field;
}
private set
{
field = value;
}
}
/// <summary>
@@ -419,9 +419,18 @@ public static class ExecutorBindingExtensions
/// <param name="agent">The agent instance.</param>
/// <param name="emitEvents">Specifies whether the agent should emit streaming events.</param>
/// <returns>An <see cref="AIAgentBinding"/> instance that wraps the provided agent.</returns>
public static ExecutorBinding BindAsExecutor(this AIAgent agent, bool emitEvents = false)
public static ExecutorBinding BindAsExecutor(this AIAgent agent, bool emitEvents)
=> new AIAgentBinding(agent, emitEvents);
/// <summary>
/// Configure an <see cref="AIAgent"/> as an executor for use in a workflow.
/// </summary>
/// <param name="agent">The agent instance.</param>
/// <param name="options">Optional configuration options for the AI agent executor. If null, default options are used.</param>
/// <returns>An <see cref="AIAgentBinding"/> instance that wraps the provided agent.</returns>
public static ExecutorBinding BindAsExecutor(this AIAgent agent, AIAgentHostOptions? options = null)
=> new AIAgentBinding(agent, options);
/// <summary>
/// Configure a <see cref="RequestPort"/> as an executor for use in a workflow.
/// </summary>
@@ -43,4 +43,13 @@ public record ExternalResponse(RequestPortInfo PortInfo, string RequestId, Porta
/// <param name="targetType">The type to which the data should be cast or converted.</param>
/// <returns>The data cast to the specified type, or null if the data cannot be cast to the specified type.</returns>
public object? DataAs(Type targetType) => this.Data.AsType(targetType);
/// <summary>
/// Attempts to retrieve the underlying data as the specified type.
/// </summary>
/// <param name="targetType">The type to which the data should be cast or converted.</param>
/// <param name="value">When this method returns <see langword="true"/>, contains the value of type
/// <paramref name="targetType"/> if the data is available and compatible.</param>
/// <returns>true if the data is present and can be cast to <paramref name="targetType"/>; otherwise, false.</returns>
public bool DataIs(Type targetType, [NotNullWhen(true)] out object? value) => this.Data.IsType(targetType, out value);
}
@@ -50,7 +50,14 @@ public sealed class GroupChatWorkflowBuilder
public Workflow Build()
{
AIAgent[] agents = this._participants.ToArray();
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => (ExecutorBinding)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
AIAgentHostOptions options = new()
{
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = true
};
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => a.BindAsExecutor(options));
Func<string, string, ValueTask<Executor>> groupChatHostFactory =
(id, runId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory));
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows;
internal interface IExternalRequestContext
{
IExternalRequestSink RegisterPort(RequestPort port);
}
@@ -200,7 +200,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
await executor.ExecuteAsync(
envelope.Message,
envelope.MessageType,
this.RunContext.Bind(receiverId, envelope.TraceContext),
this.RunContext.BindWorkflowContext(receiverId, envelope.TraceContext),
cancellationToken
).ConfigureAwait(false);
}
@@ -71,6 +71,16 @@ internal sealed class InProcessRunnerContext : IRunnerContext
this.OutgoingEvents = outgoingEvents;
}
public IExternalRequestSink RegisterPort(string executorId, RequestPort port)
{
if (!this._edgeMap.TryRegisterPort(this, executorId, port))
{
throw new InvalidOperationException($"A port with ID {port.Id} already exists.");
}
return this;
}
public async ValueTask<Executor> EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken = default)
{
this.CheckEnded();
@@ -84,7 +94,9 @@ internal sealed class InProcessRunnerContext : IRunnerContext
}
Executor executor = await registration.CreateInstanceAsync(this._runId).ConfigureAwait(false);
await executor.InitializeAsync(this.Bind(executorId), cancellationToken: cancellationToken)
executor.Configure(this.BindExternalRequestContext(executorId));
await executor.InitializeAsync(this.BindWorkflowContext(executorId), cancellationToken: cancellationToken)
.ConfigureAwait(false);
tracer?.TraceActivated(executorId);
@@ -233,10 +245,16 @@ internal sealed class InProcessRunnerContext : IRunnerContext
}
}
public IWorkflowContext Bind(string executorId, Dictionary<string, string>? traceContext = null)
public IExternalRequestContext BindExternalRequestContext(string executorId)
{
this.CheckEnded();
return new BoundContext(this, executorId, traceContext);
return new BoundExternalRequestContext(this, executorId);
}
public IWorkflowContext BindWorkflowContext(string executorId, Dictionary<string, string>? traceContext = null)
{
this.CheckEnded();
return new BoundWorkflowContext(this, executorId, traceContext);
}
public ValueTask PostAsync(ExternalRequest request)
@@ -260,7 +278,17 @@ internal sealed class InProcessRunnerContext : IRunnerContext
internal StateManager StateManager { get; } = new();
private sealed class BoundContext(
private sealed class BoundExternalRequestContext(
InProcessRunnerContext RunnerContext,
string ExecutorId) : IExternalRequestContext
{
public IExternalRequestSink RegisterPort(RequestPort port)
{
return RunnerContext.RegisterPort(ExecutorId, port);
}
}
private sealed class BoundWorkflowContext(
InProcessRunnerContext RunnerContext,
string ExecutorId,
Dictionary<string, string>? traceContext) : IWorkflowContext
@@ -312,7 +340,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
async Task InvokeCheckpointingAsync(Task<Executor> executorTask)
{
Executor executor = await executorTask.ConfigureAwait(false);
await executor.OnCheckpointingAsync(this.Bind(executor.Id), cancellationToken).ConfigureAwait(false);
await executor.OnCheckpointingAsync(this.BindWorkflowContext(executor.Id), cancellationToken).ConfigureAwait(false);
}
}
@@ -325,7 +353,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
async Task InvokeCheckpointRestoredAsync(Task<Executor> executorTask)
{
Executor executor = await executorTask.ConfigureAwait(false);
await executor.OnCheckpointRestoredAsync(this.Bind(executor.Id), cancellationToken).ConfigureAwait(false);
await executor.OnCheckpointRestoredAsync(this.BindWorkflowContext(executor.Id), cancellationToken).ConfigureAwait(false);
}
}
@@ -2,6 +2,7 @@
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows;
internal class PortBinding(RequestPort port, IExternalRequestSink sink)
{
public RequestPort Port => port;
public IExternalRequestSink Sink => sink;
public ValueTask PostRequestAsync<TRequest>(TRequest request, string? requestId = null, CancellationToken cancellationToken = default)
{
ExternalRequest externalRequest = ExternalRequest.Create(this.Port, request, requestId);
return this.Sink.PostAsync(externalRequest);
}
}
@@ -22,6 +22,14 @@ using MessageHandlerF =
System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Execution.CallResult>
>;
using PortHandlerF =
System.Func<
Microsoft.Agents.AI.Workflows.ExternalResponse, // message
Microsoft.Agents.AI.Workflows.IWorkflowContext, // context
System.Threading.CancellationToken, // cancellation
System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.ExternalResponse?>
>;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
@@ -32,10 +40,17 @@ namespace Microsoft.Agents.AI.Workflows;
/// </remarks>
public class RouteBuilder
{
private readonly IExternalRequestContext? _externalRequestContext;
private readonly Dictionary<Type, MessageHandlerF> _typedHandlers = [];
private readonly Dictionary<Type, Type> _outputTypes = [];
private readonly Dictionary<string, PortHandlerF> _portHandlers = [];
private CatchAllF? _catchAll;
internal RouteBuilder(IExternalRequestContext? externalRequestContext)
{
this._externalRequestContext = externalRequestContext;
}
internal RouteBuilder AddHandlerInternal(Type messageType, MessageHandlerF handler, Type? outputType, bool overwrite = false)
{
Throw.IfNull(messageType);
@@ -102,6 +117,60 @@ public class RouteBuilder
}
}
/// <summary>
/// Registers a port and associated handler for external requests originating from the executor. This generates a PortBinding that can be used to
/// submit requests through to the workflow Run call.
/// </summary>
/// <typeparam name="TRequest">The type of request messages that will be sent through this port.</typeparam>
/// <typeparam name="TResponse">The type of response messages that will be sent through this port.</typeparam>
/// <param name="id">A unique identifier for the port.</param>
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TResponse"/> within the workflow context. The
/// delegate is invoked for each incoming response to requests through this port.</param>
/// <param name="portBinding">A <see cref="PortBinding"/> representing this port registration providing a means to submit requests.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified response; if a port with this id is not
/// this will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
/// options.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
internal RouteBuilder AddPortHandler<TRequest, TResponse>(string id, Func<TResponse, IWorkflowContext, CancellationToken, ValueTask> handler, out PortBinding portBinding, bool overwrite = false)
{
if (this._externalRequestContext == null)
{
throw new InvalidOperationException("An external request context is required to register port handlers.");
}
RequestPort port = RequestPort.Create<TRequest, TResponse>(id);
IExternalRequestSink sink = this._externalRequestContext!.RegisterPort(port);
portBinding = new(port, sink);
if (this._portHandlers.ContainsKey(id) == overwrite)
{
this._portHandlers[id] = InvokeHandlerAsync;
}
else if (overwrite)
{
throw new InvalidOperationException($"A handler for port id {id} is not registered (overwrite = true).");
}
else
{
throw new InvalidOperationException($"A handler for port id {id} is already registered (overwrite = false).");
}
return this;
async ValueTask<ExternalResponse?> InvokeHandlerAsync(ExternalResponse response, IWorkflowContext context, CancellationToken cancellationToken)
{
if (!response.DataIs(out TResponse? typedResponse))
{
throw new InvalidOperationException($"Received response data is not of expected type {typeof(TResponse).FullName} for port {port.Id}.");
}
await handler(typedResponse, context, cancellationToken).ConfigureAwait(false);
return response;
}
}
/// <summary>
/// Registers a handler for messages of the specified input type in the workflow route.
/// </summary>
@@ -111,10 +180,12 @@ public class RouteBuilder
/// <typeparam name="TInput"></typeparam>
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
/// delegate is invoked for each incoming message of the specified type.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
/// langword="false"/> to preserve the existing handler.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
/// options.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput>(Action<TInput, IWorkflowContext, CancellationToken> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -137,10 +208,12 @@ public class RouteBuilder
/// <typeparam name="TInput"></typeparam>
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
/// delegate is invoked for each incoming message of the specified type.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
/// langword="false"/> to preserve the existing handler.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
/// options.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput>(Action<TInput, IWorkflowContext> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -163,10 +236,12 @@ public class RouteBuilder
/// <typeparam name="TInput"></typeparam>
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
/// delegate is invoked for each incoming message of the specified type.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
/// langword="false"/> to preserve the existing handler.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
/// options.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput>(Func<TInput, IWorkflowContext, CancellationToken, ValueTask> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -189,10 +264,12 @@ public class RouteBuilder
/// <typeparam name="TInput"></typeparam>
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
/// delegate is invoked for each incoming message of the specified type.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
/// langword="false"/> to preserve the existing handler.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
/// options.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput>(Func<TInput, IWorkflowContext, ValueTask> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -216,9 +293,11 @@ public class RouteBuilder
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
/// <param name="handler">A function that processes messages of type <typeparamref name="TInput"/> within the workflow context and returns
/// a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, CancellationToken, TResult> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -242,9 +321,11 @@ public class RouteBuilder
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
/// <param name="handler">A function that processes messages of type <typeparamref name="TInput"/> within the workflow context and returns
/// a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, TResult> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -268,9 +349,11 @@ public class RouteBuilder
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
/// <param name="handler">A function that processes messages of type <typeparamref name="TInput"/> within the workflow context and returns
/// a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TResult>> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -279,7 +362,7 @@ public class RouteBuilder
async ValueTask<CallResult> WrappedHandlerAsync(object message, IWorkflowContext context, CancellationToken cancellationToken)
{
TResult result = await handler.Invoke((TInput)message, context, cancellationToken).ConfigureAwait(false);
TResult result = await handler((TInput)message, context, cancellationToken).ConfigureAwait(false);
return CallResult.ReturnResult(result);
}
}
@@ -294,9 +377,11 @@ public class RouteBuilder
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
/// <param name="handler">A function that processes messages of type <typeparamref name="TInput"/> within the workflow context and returns
/// a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, ValueTask<TResult>> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -330,9 +415,11 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context. The delegate is invoked for each incoming message not otherwise handled.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll(Func<PortableValue, IWorkflowContext, CancellationToken, ValueTask> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -354,9 +441,11 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context. The delegate is invoked for each incoming message not otherwise handled.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll(Func<PortableValue, IWorkflowContext, ValueTask> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -378,9 +467,11 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context and returns a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, CancellationToken, ValueTask<TResult>> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -402,9 +493,11 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context and returns a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, ValueTask<TResult>> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -426,9 +519,11 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context. The delegate is invoked for each incoming message not otherwise handled.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll(Action<PortableValue, IWorkflowContext, CancellationToken> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -450,9 +545,11 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context. The delegate is invoked for each incoming message not otherwise handled.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll(Action<PortableValue, IWorkflowContext> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -474,9 +571,11 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context and returns a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, CancellationToken, TResult> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -498,9 +597,11 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context and returns a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, TResult> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -514,5 +615,29 @@ public class RouteBuilder
}
}
internal MessageRouter Build() => new(this._typedHandlers, [.. this._outputTypes.Values], this._catchAll);
private void RegisterPortHandlerRouter()
{
Dictionary<string, PortHandlerF> portHandlers = this._portHandlers;
this.AddHandler<ExternalResponse, ExternalResponse?>(InvokeHandlerAsync);
ValueTask<ExternalResponse?> InvokeHandlerAsync(ExternalResponse response, IWorkflowContext context, CancellationToken cancellationToken)
{
if (portHandlers.TryGetValue(response.PortInfo.PortId, out PortHandlerF? portHandler))
{
return portHandler(response, context, cancellationToken);
}
throw new InvalidOperationException($"Unknown port {response.PortInfo}");
}
}
internal MessageRouter Build()
{
if (this._portHandlers.Count > 0)
{
this.RegisterPortHandlerRouter();
}
return new(this._typedHandlers, [.. this._outputTypes.Values], this._catchAll);
}
}
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -8,51 +10,168 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal record AIAgentHostState(JsonElement? ThreadState, bool? CurrentTurnEmitEvents);
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
{
private readonly bool _emitEvents;
private readonly AIAgent _agent;
private AgentThread? _thread;
private readonly AIAgentHostOptions _options;
public AIAgentHostExecutor(AIAgent agent, bool emitEvents = false) : base(id: agent.GetDescriptiveId())
private AgentThread? _thread;
private bool? _currentTurnEmitEvents;
private AIContentExternalHandler<UserInputRequestContent, UserInputResponseContent>? _userInputHandler;
private AIContentExternalHandler<FunctionCallContent, FunctionResultContent>? _functionCallHandler;
private static readonly ChatProtocolExecutorOptions s_defaultChatProtocolOptions = new()
{
AutoSendTurnToken = false,
StringMessageChatRole = ChatRole.User
};
public AIAgentHostExecutor(AIAgent agent, AIAgentHostOptions options) : base(id: agent.GetDescriptiveId(),
s_defaultChatProtocolOptions,
declareCrossRunShareable: false) // Explicitly false, because we maintain turn state on the instance
{
this._agent = agent;
this._emitEvents = emitEvents;
this._options = options;
}
private async Task<AgentThread> EnsureThreadAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
private RouteBuilder ConfigureUserInputRoutes(RouteBuilder routeBuilder)
{
this._userInputHandler = new AIContentExternalHandler<UserInputRequestContent, UserInputResponseContent>(
ref routeBuilder,
portId: $"{this.Id}_UserInput",
intercepted: this._options.InterceptUserInputRequests,
handler: this.HandleUserInputResponseAsync);
this._functionCallHandler = new AIContentExternalHandler<FunctionCallContent, FunctionResultContent>(
ref routeBuilder,
portId: $"{this.Id}_FunctionCall",
intercepted: this._options.InterceptUnterminatedFunctionCalls,
handler: this.HandleFunctionResultAsync);
return routeBuilder;
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
routeBuilder = base.ConfigureRoutes(routeBuilder);
return this.ConfigureUserInputRoutes(routeBuilder);
}
private ValueTask HandleUserInputResponseAsync(
UserInputResponseContent response,
IWorkflowContext context,
CancellationToken cancellationToken)
{
if (!this._userInputHandler!.MarkRequestAsHandled(response.Id))
{
throw new InvalidOperationException($"No pending UserInputRequest found with id '{response.Id}'.");
}
List<ChatMessage> implicitTurnMessages = [new ChatMessage(ChatRole.User, [response])];
// ContinueTurnAsync owns failing to emit a TurnToken if this response does not clear up all remaining outstanding requests.
return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
}
private ValueTask HandleFunctionResultAsync(
FunctionResultContent result,
IWorkflowContext context,
CancellationToken cancellationToken)
{
if (!this._functionCallHandler!.MarkRequestAsHandled(result.CallId))
{
throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'.");
}
List<ChatMessage> implicitTurnMessages = [new ChatMessage(ChatRole.Tool, [result])];
return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
}
public bool ShouldEmitStreamingEvents(bool? emitEvents)
=> emitEvents ?? this._options.EmitAgentUpdateEvents ?? false;
private async ValueTask<AgentThread> EnsureThreadAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
this._thread ??= await this._agent.GetNewThreadAsync(cancellationToken).ConfigureAwait(false);
private const string ThreadStateKey = nameof(_thread);
private const string UserInputRequestStateKey = nameof(_userInputHandler);
private const string FunctionCallRequestStateKey = nameof(_functionCallHandler);
private const string AIAgentHostStateKey = nameof(AIAgentHostState);
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
Task threadTask = Task.CompletedTask;
if (this._thread is not null)
{
JsonElement threadValue = this._thread.Serialize();
threadTask = context.QueueStateUpdateAsync(ThreadStateKey, threadValue, cancellationToken: cancellationToken).AsTask();
}
AIAgentHostState state = new(this._thread?.Serialize(), this._currentTurnEmitEvents);
Task coreStateTask = context.QueueStateUpdateAsync(AIAgentHostStateKey, state, cancellationToken: cancellationToken).AsTask();
Task userInputRequestsTask = this._userInputHandler?.OnCheckpointingAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task functionCallRequestsTask = this._functionCallHandler?.OnCheckpointingAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask();
await Task.WhenAll(threadTask, baseTask).ConfigureAwait(false);
await Task.WhenAll(coreStateTask, userInputRequestsTask, functionCallRequestsTask, baseTask).ConfigureAwait(false);
}
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
JsonElement? threadValue = await context.ReadStateAsync<JsonElement?>(ThreadStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
if (threadValue.HasValue)
Task userInputRestoreTask = this._userInputHandler?.OnCheckpointRestoredAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task functionCallRestoreTask = this._functionCallHandler?.OnCheckpointRestoredAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
AIAgentHostState? state = await context.ReadStateAsync<AIAgentHostState>(AIAgentHostStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
if (state != null)
{
this._thread = await this._agent.DeserializeThreadAsync(threadValue.Value, cancellationToken: cancellationToken).ConfigureAwait(false);
this._thread = state.ThreadState.HasValue
? await this._agent.DeserializeThreadAsync(state.ThreadState.Value, cancellationToken: cancellationToken).ConfigureAwait(false)
: null;
this._currentTurnEmitEvents = state.CurrentTurnEmitEvents;
}
await Task.WhenAll(userInputRestoreTask, functionCallRestoreTask).ConfigureAwait(false);
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
}
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
private bool HasOutstandingRequests => (this._userInputHandler?.HasPendingRequests == true)
|| (this._functionCallHandler?.HasPendingRequests == true);
// While we save this on the instance, we are not cross-run shareable, but as AgentBinding uses the factory pattern this is not an issue
private async ValueTask ContinueTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken)
{
if (emitEvents ?? this._emitEvents)
this._currentTurnEmitEvents = emitEvents;
if (this._options.ForwardIncomingMessages)
{
await context.SendMessageAsync(messages, cancellationToken).ConfigureAwait(false);
}
IEnumerable<ChatMessage> filteredMessages = this._options.ReassignOtherAgentsAsUsers
? messages.Select(m => m.ChatAssistantToUserIfNotFromNamed(this._agent.Name ?? this._agent.Id))
: messages;
AgentResponse response = await this.InvokeAgentAsync(filteredMessages, context, emitEvents, cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(response.Messages is List<ChatMessage> list ? list : response.Messages.ToList(), cancellationToken)
.ConfigureAwait(false);
// If we have no outstanding requests, we can yield a turn token back to the workflow.
if (!this.HasOutstandingRequests)
{
await context.SendMessageAsync(new TurnToken(this._currentTurnEmitEvents), cancellationToken).ConfigureAwait(false);
this._currentTurnEmitEvents = null; // Possibly not actually necessary, but cleaning this up makes it clearer when debugging
}
}
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> this.ContinueTurnAsync(messages, context, this.ShouldEmitStreamingEvents(emitEvents), cancellationToken);
private async ValueTask<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default)
{
#pragma warning disable MEAI001
Dictionary<string, UserInputRequestContent> userInputRequests = new();
Dictionary<string, FunctionCallContent> functionCalls = new();
AgentResponse response;
if (emitEvents)
{
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
// Run the agent in streaming mode only when agent run update events are to be emitted.
IAsyncEnumerable<AgentResponseUpdate> agentStream = this._agent.RunStreamingAsync(
messages,
@@ -60,28 +179,70 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
cancellationToken: cancellationToken);
List<AgentResponseUpdate> updates = [];
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
// TODO: FunctionCall request handling, and user info request handling.
// In some sense: We should just let it be handled as a ChatMessage, though we should consider
// providing some mechanisms to help the user complete the request, or route it out of the
// workflow.
ExtractUnservicedRequests(update.Contents);
updates.Add(update);
}
await context.SendMessageAsync(updates.ToAgentResponse().Messages, cancellationToken: cancellationToken).ConfigureAwait(false);
response = updates.ToAgentResponse();
}
else
{
// Otherwise, run the agent in non-streaming mode.
AgentResponse response = await this._agent.RunAsync(
messages,
await this.EnsureThreadAsync(context, cancellationToken).ConfigureAwait(false),
cancellationToken: cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(response.Messages, cancellationToken: cancellationToken).ConfigureAwait(false);
response = await this._agent.RunAsync(messages,
await this.EnsureThreadAsync(context, cancellationToken).ConfigureAwait(false),
cancellationToken: cancellationToken)
.ConfigureAwait(false);
ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents));
}
if (this._options.EmitAgentResponseEvents == true)
{
await context.AddEventAsync(new AgentResponseEvent(this.Id, response), cancellationToken).ConfigureAwait(false);
}
if (userInputRequests.Count > 0 || functionCalls.Count > 0)
{
Task userInputTask = this._userInputHandler?.ProcessRequestContentsAsync(userInputRequests, context, cancellationToken) ?? Task.CompletedTask;
Task functionCallTask = this._functionCallHandler?.ProcessRequestContentsAsync(functionCalls, context, cancellationToken) ?? Task.CompletedTask;
await Task.WhenAll(userInputTask, functionCallTask)
.ConfigureAwait(false);
}
return response;
void ExtractUnservicedRequests(IEnumerable<AIContent> contents)
{
foreach (AIContent content in contents)
{
if (content is UserInputRequestContent userInputRequest)
{
// It is an error to simultaneously have multiple outstanding user input requests with the same ID.
userInputRequests.Add(userInputRequest.Id, userInputRequest);
}
else if (content is UserInputResponseContent userInputResponse)
{
// If the set of messages somehow already has a corresponding user input response, remove it.
_ = userInputRequests.Remove(userInputResponse.Id);
}
else if (content is FunctionCallContent functionCall)
{
// For function calls, we emit an event to notify the workflow.
//
// possibility 1: this will be handled inline by the agent abstraction
// possibility 2: this will not be handled inline by the agent abstraction
functionCalls.Add(functionCall.CallId, functionCall);
}
else if (content is FunctionResultContent functionResult)
{
_ = functionCalls.Remove(functionResult.CallId);
}
}
}
#pragma warning restore MEAI001
}
}
@@ -0,0 +1,85 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class AIContentExternalHandler<TRequestContent, TResponseContent>
where TRequestContent : AIContent
where TResponseContent : AIContent
{
private readonly PortBinding? _portBinding;
private ConcurrentDictionary<string, TRequestContent> _pendingRequests = new();
public AIContentExternalHandler(ref RouteBuilder routeBuilder, string portId, bool intercepted, Func<TResponseContent, IWorkflowContext, CancellationToken, ValueTask> handler)
{
if (intercepted)
{
this._portBinding = null;
routeBuilder = routeBuilder.AddHandler(handler);
}
else
{
routeBuilder = routeBuilder.AddPortHandler<TRequestContent, TResponseContent>(portId, handler, out this._portBinding);
}
}
public bool HasPendingRequests => !this._pendingRequests.IsEmpty;
public Task ProcessRequestContentsAsync(Dictionary<string, TRequestContent> requests, IWorkflowContext context, CancellationToken cancellationToken = default)
{
IEnumerable<Task> requestTasks = from string requestId in requests.Keys
select this.ProcessRequestContentAsync(requestId, requests[requestId], context, cancellationToken)
.AsTask();
return Task.WhenAll(requestTasks);
}
public ValueTask ProcessRequestContentAsync(string id, TRequestContent requestContent, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (!this._pendingRequests.TryAdd(id, requestContent))
{
throw new InvalidOperationException($"A pending request with ID '{id}' already exists.");
}
return this.IsIntercepted
? context.SendMessageAsync(requestContent, cancellationToken: cancellationToken)
: this._portBinding.PostRequestAsync(requestContent, id, cancellationToken);
}
public bool MarkRequestAsHandled(string id)
{
return this._pendingRequests.TryRemove(id, out _);
}
[MemberNotNullWhen(false, nameof(_portBinding))]
private bool IsIntercepted => this._portBinding == null;
private static string MakeKey(string id) => $"{id}_PendingRequests";
public async ValueTask OnCheckpointingAsync(string id, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Dictionary<string, TRequestContent> pendingRequestsCopy = new(this._pendingRequests);
await context.QueueStateUpdateAsync(MakeKey(id), pendingRequestsCopy, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
public async ValueTask OnCheckpointRestoredAsync(string id, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Dictionary<string, TRequestContent>? loadedState =
await context.ReadStateAsync<Dictionary<string, TRequestContent>>(MakeKey(id), cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (loadedState != null)
{
this._pendingRequests = new ConcurrentDictionary<string, TRequestContent>(loadedState);
}
}
}
@@ -1,44 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>
/// Executor that runs the agent and forwards all messages, input and output, to the next executor.
/// </summary>
internal sealed class AgentRunStreamingExecutor(AIAgent agent, bool includeInputInOutput)
: ChatProtocolExecutor(agent.GetDescriptiveId(), DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
{
private static ChatProtocolExecutorOptions DefaultOptions => new()
{
StringMessageChatRole = ChatRole.User
};
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
{
List<ChatMessage>? roleChanged = messages.ChangeAssistantToUserForOtherParticipants(agent.Name ?? agent.Id);
List<AgentResponseUpdate> updates = [];
await foreach (var update in agent.RunStreamingAsync(messages, cancellationToken: cancellationToken).ConfigureAwait(false))
{
updates.Add(update);
if (emitEvents is true)
{
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
}
}
roleChanged.ResetUserToAssistantForChangedRoles();
List<ChatMessage> result = includeInputInOutput ? [.. messages] : [];
result.AddRange(updates.ToAgentResponse().Messages);
await context.SendMessageAsync(result, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public new ValueTask ResetAsync() => base.ResetAsync();
}
@@ -8,10 +8,10 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>
/// Provides an executor that batches received chat messages that it then releases when
/// Provides an executor that aggregates received chat messages that it then releases when
/// receiving a <see cref="TurnToken"/>.
/// </summary>
internal sealed class CollectChatMessagesExecutor(string id) : ChatProtocolExecutor(id, declareCrossRunShareable: true), IResettableExecutor
internal sealed class AggregateTurnMessagesExecutor(string id) : ChatProtocolExecutor(id, declareCrossRunShareable: true), IResettableExecutor
{
/// <inheritdoc/>
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
@@ -112,18 +112,11 @@ internal sealed class RequestInfoExecutor : Executor
public async ValueTask<ExternalResponse?> HandleAsync(ExternalResponse message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Throw.IfNull(message);
Throw.IfNull(message.Data);
if (message.PortInfo.PortId != this.Port.Id)
if (!this.Port.IsResponsePort(message))
{
return null;
}
object data = message.DataAs(this.Port.Response) ??
throw new InvalidOperationException(
$"Message type {message.Data.TypeId} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}.");
if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest))
{
await context.SendMessageAsync(originalRequest.RewrapResponse(message), cancellationToken: cancellationToken).ConfigureAwait(false);
@@ -133,6 +126,11 @@ internal sealed class RequestInfoExecutor : Executor
await context.SendMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false);
}
if (!message.Data.IsType(this.Port.Response, out object? data))
{
throw this.Port.CreateExceptionForType(message);
}
await context.SendMessageAsync(data, cancellationToken: cancellationToken).ConfigureAwait(false);
return message;
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal static class RequestPortExtensions
{
/// <summary>
/// Attempts to process the incoming <see cref="ExternalResponse"/> as a response to a request sent
/// through the specified <see cref="RequestPort"/>. If the response is to a different port, returns
/// <see langword="false"/>. If the port matches, but the response data cannot be interpreted as the
/// expected response type, throws an <see cref="InvalidOperationException"/>. Otherwise, returns
/// <see langword="true"/>.
/// </summary>
/// <param name="port">The request port through which the original request was sent.</param>
/// <param name="response">The candidate response to be processed</param>
/// <returns><see langword="true"/> if the response is for the specified port and the data could be
/// interpreted as the expected response type; otherwise, <see langword="false"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if the response is for the specified port,
/// but the data could not be interpreted as the expected response type.</exception>
public static bool ShouldProcessResponse(this RequestPort port, ExternalResponse response)
{
Throw.IfNull(response);
Throw.IfNull(response.Data);
if (!port.IsResponsePort(response))
{
return false;
}
if (!response.Data.IsType(port.Response))
{
throw port.CreateExceptionForType(response);
}
return true;
}
internal static bool IsResponsePort(this RequestPort port, ExternalResponse response)
=> Throw.IfNull(response).PortInfo.PortId == port.Id;
internal static InvalidOperationException CreateExceptionForType(this RequestPort port, ExternalResponse response)
=> new($"Message type {response.Data.TypeId} is not assignable to the response type {port.Response.Name}" +
$" of input port {port.Id}.");
}
@@ -7,6 +7,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
@@ -188,6 +189,16 @@ public class Workflow
await this.TryResetExecutorRegistrationsAsync().ConfigureAwait(false);
}
private sealed class NoOpExternalRequestContext : IExternalRequestContext, IExternalRequestSink
{
public ValueTask PostAsync(ExternalRequest request) => default;
IExternalRequestSink IExternalRequestContext.RegisterPort(RequestPort port)
{
return this;
}
}
/// <summary>
/// Retrieves a <see cref="ProtocolDescriptor"/> defining how to interact with this workflow.
/// </summary>
@@ -199,6 +210,8 @@ public class Workflow
ExecutorBinding startExecutorRegistration = this.ExecutorBindings[this.StartExecutorId];
Executor startExecutor = await startExecutorRegistration.CreateInstanceAsync(string.Empty)
.ConfigureAwait(false);
startExecutor.Configure(new NoOpExternalRequestContext());
return startExecutor.DescribeProtocol();
}
}
@@ -93,7 +93,7 @@ internal static partial class WorkflowsJsonUtilities
[JsonSerializable(typeof(TurnToken))]
// Built-in Executor State Types
[JsonSerializable(typeof(AIAgentHostExecutor))]
[JsonSerializable(typeof(AIAgentHostState))]
// Event Types
//[JsonSerializable(typeof(WorkflowEvent))]
@@ -0,0 +1,278 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class AIAgentHostExecutorTests
{
private const string TestAgentId = nameof(TestAgentId);
private const string TestAgentName = nameof(TestAgentName);
private static readonly string[] s_messageStrings = [
"",
"Hello world!",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
"Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus."
];
private static List<ChatMessage> TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings);
[Theory]
[InlineData(null, null)]
[InlineData(null, true)]
[InlineData(null, false)]
[InlineData(true, null)]
[InlineData(true, true)]
[InlineData(true, false)]
[InlineData(false, null)]
[InlineData(false, true)]
[InlineData(false, false)]
public async Task Test_AgentHostExecutor_EmitsStreamingUpdatesIFFConfiguredAsync(bool? executorSetting, bool? turnSetting)
{
// Arrange
TestRunContext testContext = new();
TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
AIAgentHostExecutor executor = new(agent, new() { EmitAgentUpdateEvents = executorSetting });
testContext.ConfigureExecutor(executor);
// Act
await executor.TakeTurnAsync(new(turnSetting), testContext.BindWorkflowContext(executor.Id));
// Assert
// The rules are: TurnToken overrides Agent, if set. Default to false, if both unset.
bool expectingEvents = turnSetting ?? executorSetting ?? false;
AgentResponseUpdateEvent[] updates = testContext.Events.OfType<AgentResponseUpdateEvent>().ToArray();
if (expectingEvents)
{
// The way TestReplayAgent is set up, it will emit one update per non-empty AIContent
List<AIContent> expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList();
updates.Should().HaveCount(expectedUpdateContents.Count);
for (int i = 0; i < updates.Length; i++)
{
AgentResponseUpdateEvent updateEvent = updates[i];
AIContent expectedUpdateContent = expectedUpdateContents[i];
updateEvent.ExecutorId.Should().Be(agent.GetDescriptiveId());
AgentResponseUpdate update = updateEvent.Update;
update.AuthorName.Should().Be(TestAgentName);
update.AgentId.Should().Be(TestAgentId);
update.Contents.Should().HaveCount(1);
update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent);
}
}
else
{
updates.Should().BeEmpty();
}
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Test_AgentHostExecutor_EmitsResponseIFFConfiguredAsync(bool executorSetting)
{
// Arrange
TestRunContext testContext = new();
TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
AIAgentHostExecutor executor = new(agent, new() { EmitAgentResponseEvents = executorSetting });
testContext.ConfigureExecutor(executor);
// Act
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
// Assert
AgentResponseEvent[] updates = testContext.Events.OfType<AgentResponseEvent>().ToArray();
if (executorSetting)
{
updates.Should().HaveCount(1);
AgentResponseEvent responseEvent = updates[0];
responseEvent.ExecutorId.Should().Be(agent.GetDescriptiveId());
AgentResponse response = responseEvent.Response;
response.AgentId.Should().Be(TestAgentId);
response.Messages.Should().HaveCount(TestMessages.Count - 1);
for (int i = 0; i < response.Messages.Count; i++)
{
ChatMessage responseMessage = response.Messages[i];
ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message
responseMessage.AuthorName.Should().Be(TestAgentName);
responseMessage.Text.Should().Be(expectedMessage.Text);
}
}
else
{
updates.Should().BeEmpty();
}
}
private static ChatMessage UserMessage => new(ChatRole.User, "Hello from User!") { AuthorName = "User" };
private static ChatMessage AssistantMessage => new(ChatRole.Assistant, "Hello from Assistant!") { AuthorName = "User" };
private static ChatMessage TestAgentMessage => new(ChatRole.Assistant, $"Hello from {TestAgentName}!") { AuthorName = TestAgentName };
[Theory]
[InlineData(true, true, false, false)]
[InlineData(true, true, false, true)]
[InlineData(true, true, true, false)]
[InlineData(true, true, true, true)]
[InlineData(true, false, false, false)]
[InlineData(true, false, false, true)]
[InlineData(true, false, true, false)]
[InlineData(true, false, true, true)]
[InlineData(false, true, false, false)]
[InlineData(false, true, false, true)]
[InlineData(false, true, true, false)]
[InlineData(false, true, true, true)]
[InlineData(false, false, false, false)]
[InlineData(false, false, false, true)]
[InlineData(false, false, true, false)]
[InlineData(false, false, true, true)]
public async Task Test_AgentHostExecutor_ReassignsRolesIFFConfiguredAsync(bool executorSetting, bool includeUser, bool includeSelfMessages, bool includeOtherMessages)
{
// Arrange
TestRunContext testContext = new();
RoleCheckAgent agent = new(false, TestAgentId, TestAgentName);
AIAgentHostExecutor executor = new(agent, new() { ReassignOtherAgentsAsUsers = executorSetting });
testContext.ConfigureExecutor(executor);
List<ChatMessage> messages = [];
if (includeUser)
{
messages.Add(UserMessage);
}
if (includeSelfMessages)
{
messages.Add(TestAgentMessage);
}
if (includeOtherMessages)
{
messages.Add(AssistantMessage);
}
// Act
await executor.Router.RouteMessageAsync(messages, testContext.BindWorkflowContext(executor.Id));
Func<Task> act = async () => await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
// Assert
bool shouldThrow = includeOtherMessages && !executorSetting;
if (shouldThrow)
{
await act.Should().ThrowAsync<InvalidOperationException>();
}
else
{
await act.Should().NotThrowAsync();
}
}
[Theory]
[InlineData(true, TestAgentRequestType.FunctionCall)]
[InlineData(false, TestAgentRequestType.FunctionCall)]
//[InlineData(true, TestAgentRequestType.UserInputRequest)] TODO: Enable when we support polymorphic routing
[InlineData(false, TestAgentRequestType.UserInputRequest)]
public async Task Test_AgentHostExecutor_InterceptsRequestsIFFConfiguredAsync(bool intercept, TestAgentRequestType requestType)
{
const int UnpairedRequestCount = 2;
const int PairedRequestCount = 3;
// Arrange
TestRunContext testContext = new();
TestRequestAgent agent = new(requestType, UnpairedRequestCount, PairedRequestCount, TestAgentId, TestAgentName);
AIAgentHostOptions agentHostOptions = requestType switch
{
TestAgentRequestType.FunctionCall =>
new()
{
EmitAgentResponseEvents = true,
InterceptUnterminatedFunctionCalls = intercept
},
TestAgentRequestType.UserInputRequest =>
new()
{
EmitAgentResponseEvents = true,
InterceptUserInputRequests = intercept
},
_ => throw new NotSupportedException()
};
AIAgentHostExecutor executor = new(agent, agentHostOptions);
testContext.ConfigureExecutor(executor);
// Act
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
// Assert
List<object> responses;
if (intercept)
{
// We expect to have a sent message containing the requests as an ExternalRequest
switch (requestType)
{
case TestAgentRequestType.FunctionCall:
responses = ExtractAndValidateRequestContents<FunctionCallContent>();
break;
case TestAgentRequestType.UserInputRequest:
responses = ExtractAndValidateRequestContents<UserInputRequestContent>();
break;
default:
throw new NotSupportedException();
}
List<object> ExtractAndValidateRequestContents<TRequest>() where TRequest : AIContent
{
IEnumerable<TRequest> requests = testContext.QueuedMessages.Should().ContainKey(executor.Id)
.WhoseValue
.Select(envelope => envelope.Message as TRequest)
.Where(item => item is not null)
.Select(item => item!);
return agent.ValidateUnpairedRequests(requests).ToList();
}
}
else
{
responses = agent.ValidateUnpairedRequests([.. testContext.ExternalRequests]).ToList<object>();
}
// Act 2
foreach (object response in responses.Take(UnpairedRequestCount - 1))
{
await executor.Router.RouteMessageAsync(response, testContext.BindWorkflowContext(executor.Id));
}
// Assert 2
// Since we are not finished, we expect the agent to not have produced a final response (="Remaining: 1")
AgentResponseEvent lastResponseEvent = testContext.Events.OfType<AgentResponseEvent>().Should().NotBeEmpty()
.And.Subject.Last();
lastResponseEvent.Response.Text.Should().Be("Remaining: 1");
// Act 3
object finalResponse = responses.Last();
await executor.Router.RouteMessageAsync(finalResponse, testContext.BindWorkflowContext(executor.Id));
// Assert 3
// Now that we are finished, we expect the agent to have produced a final response
lastResponseEvent = testContext.Events.OfType<AgentResponseEvent>().Should().NotBeEmpty()
.And.Subject.Last();
lastResponseEvent.Response.Text.Should().Be("Done");
}
}
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal sealed class DynamicPortsExecutor<TRequest, TResponse>(string id, params IEnumerable<string> ports) : Executor(id)
{
public Dictionary<string, PortBinding> PortBindings { get; } = new();
public ConcurrentDictionary<string, ConcurrentQueue<TResponse>> ReceivedResponses { get; } = new();
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
foreach (string portId in ports)
{
routeBuilder = routeBuilder
.AddPortHandler<TRequest, TResponse>(portId,
(response, context, cancellationToken) =>
{
this.ReceivedResponses.GetOrAdd(portId, _ => new()).Enqueue(response);
return default;
}, out PortBinding? binding);
this.PortBindings[portId] = binding;
}
return routeBuilder;
}
public ValueTask PostRequestAsync(string portId, TRequest request, TestRunContext testContext, string? requestId = null)
{
PortBinding binding = this.PortBindings[portId];
return binding.Sink.PostAsync(ExternalRequest.Create(binding.Port, request, requestId));
}
}
@@ -0,0 +1,99 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class DynamicRequestPortTests
{
private sealed class RequestPortTestContext
{
private const string PortId = "Port1";
private const string ExecutorId = "Executor1";
public RequestPortTestContext()
{
this.Executor = new(ExecutorId, PortId);
this.Executor.Configure(this.ExternalRequestContext);
}
public TestRunContext RunContext { get; } = new();
public ExternalRequestContext ExternalRequestContext { get; } = new();
public DynamicPortsExecutor<string, int> Executor { get; }
public PortBinding PortBinding => this.Executor.PortBindings[PortId];
public ExternalRequest Request => this.ExternalRequestContext.ExternalRequests[0];
public static async ValueTask<RequestPortTestContext> CreateAsync(string requestData = "Request", bool validate = true)
{
RequestPortTestContext result = new();
await result.Executor.PostRequestAsync(PortId, requestData, result.RunContext);
if (validate)
{
result.ExternalRequestContext
.ExternalRequests.Should().HaveCount(1)
.And.AllSatisfy(request => request.PortInfo.Should().Be(result.PortBinding.Port.ToPortInfo()));
}
return result;
}
public ValueTask<object?> InvokeExecutorWithResponseAsync(ExternalResponse response)
=> this.Executor.ExecuteAsync(response, new(typeof(ExternalResponse)), this.RunContext.BindWorkflowContext(this.Executor.Id));
}
private sealed class ExternalRequestContext : IExternalRequestContext, IExternalRequestSink
{
public List<ExternalRequest> ExternalRequests { get; } = new();
public ValueTask PostAsync(ExternalRequest request)
{
this.ExternalRequests.Add(request);
return default;
}
public IExternalRequestSink RegisterPort(RequestPort port)
{
return this;
}
}
[Fact]
public async Task Test_DynamicRequestPort_DeliversExpectedResponseAsync()
{
RequestPortTestContext context = await RequestPortTestContext.CreateAsync();
ExternalRequest request = context.Request;
await context.InvokeExecutorWithResponseAsync(request.CreateResponse(13));
string portId = request.PortInfo.PortId;
context.Executor.ReceivedResponses.Should().HaveCount(1)
.And.ContainKey(portId);
context.Executor.ReceivedResponses[portId].Should().HaveCount(1);
context.Executor.ReceivedResponses[portId].First().Should().Be(13);
}
[Fact]
public async Task Test_DynamicRequestPort_ThrowsOnWrongPortAsync()
{
RequestPortTestContext context = await RequestPortTestContext.CreateAsync();
ExternalRequest request = context.Request;
ExternalRequest fakeRequest = new(RequestPort.Create<string, int>("port2").ToPortInfo(), request.RequestId, request.Data);
Func<Task> act = async () => await context.InvokeExecutorWithResponseAsync(fakeRequest.CreateResponse(13));
(await act.Should().ThrowAsync<TargetInvocationException>())
.WithInnerException<InvalidOperationException>();
}
}
@@ -1,24 +1,95 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Agents.AI.Workflows.Specialized;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class EdgeMapSmokeTests
{
[Fact]
public async Task Test_EdgeMap_MaintainsFanInEdgeStateAsync()
public async Task Test_EdgeMap_RoutesStaticPortAsync()
{
TestRunContext runContext = new();
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
RequestPort staticPort = RequestPort.Create<string, int>("port1");
RequestInfoExecutor executor = new(staticPort);
EdgeMap edgeMap = new(runContext, [], [staticPort], executor.Id, null);
runContext.ConfigureExecutor(executor, edgeMap);
ExternalResponse responseMessage = new(staticPort.ToPortInfo(), "Request1", new(12));
DeliveryMapping? mapping = await edgeMap.PrepareDeliveryForResponseAsync(responseMessage);
mapping.Should().NotBeNull();
List<MessageDelivery> deliveries = mapping.Deliveries.ToList();
deliveries.Should().HaveCount(1).And.AllSatisfy(delivery => delivery.TargetId.Should().Be(executor.Id));
deliveries[0].Envelope.Message.Should().Be(responseMessage);
}
[Fact]
public async Task Test_EdgeMap_RoutesDynamicPortAsync()
{
TestRunContext runContext = new();
DynamicPortsExecutor<string, int> executor = new("executor1", "port1", "port2");
EdgeMap edgeMap = new(runContext, [], [], executor.Id, null);
runContext.ConfigureExecutor(executor, edgeMap);
await RunPortTestAsync("port1");
await RunPortTestAsync("port2");
async ValueTask RunPortTestAsync(string portId)
{
PortBinding binding = executor.PortBindings[portId];
ExternalResponse responseMessage = new(binding.Port.ToPortInfo(), $"RequestFor[{portId}]", new(10));
DeliveryMapping? mapping = await edgeMap.PrepareDeliveryForResponseAsync(responseMessage);
mapping.Should().NotBeNull();
List<MessageDelivery> deliveries = mapping.Deliveries.ToList();
deliveries.Should().HaveCount(1).And.AllSatisfy(delivery => delivery.TargetId.Should().Be(executor.Id));
deliveries[0].Envelope.Message.Should().Be(responseMessage);
}
}
[Fact]
public async Task Test_EdgeMap_DoesNotRouteUnregisteredPortAsync()
{
TestRunContext runContext = new();
RequestPort staticPort = RequestPort.Create<string, int>("port1");
RequestInfoExecutor staticExecutor = new(staticPort);
DynamicPortsExecutor<string, int> executor = new("executor1", "port2", "port3");
EdgeMap edgeMap = new(runContext, [], [staticPort], executor.Id, null);
runContext.ConfigureExecutors([staticExecutor, executor], edgeMap);
await RunPortTestAsync("port4");
async ValueTask RunPortTestAsync(string portId)
{
RequestPort fakePort = RequestPort.Create<string, int>(portId);
ExternalResponse responseMessage = new(fakePort.ToPortInfo(), $"RequestFor[{portId}]", new(10));
Func<Task<DeliveryMapping?>> mappingTask = async () => await edgeMap.PrepareDeliveryForResponseAsync(responseMessage);
await mappingTask.Should().ThrowAsync<InvalidOperationException>();
}
}
[Fact]
public async Task Test_EdgeMap_MaintainsFanInEdgeStateAsync()
{
TestRunContext runContext = new();
Dictionary<string, HashSet<Edge>> workflowEdges = [];
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
@@ -26,9 +97,15 @@ public class EdgeMapSmokeTests
workflowEdges["executor1"] = [fanInEdge];
workflowEdges["executor2"] = [fanInEdge];
EdgeMap edgeMap = new(runContext, workflowEdges, [], "executor1", null);
runContext.ConfigureExecutors(
[
new ForwardMessageExecutor<string>("executor1"),
new ForwardMessageExecutor<string>("executor2"),
new ForwardMessageExecutor<string>("executor3")
], edgeMap);
DeliveryMapping? mapping = await edgeMap.PrepareDeliveryForEdgeAsync(fanInEdge, new("part1", "executor1"));
mapping.Should().BeNull();
@@ -28,9 +28,11 @@ public class EdgeRunnerTests
: null;
TestRunContext runContext = new();
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
runContext.ConfigureExecutors(
[
new ForwardMessageExecutor<string>("executor1"),
new ForwardMessageExecutor<string>("executor2")
]);
DirectEdgeData edgeData = new("executor1", "executor2", new EdgeId(0), condition);
DirectEdgeRunner runner = new(runContext, edgeData);
@@ -78,9 +80,11 @@ public class EdgeRunnerTests
{
TestRunContext runContext = new();
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
runContext.ConfigureExecutors([
new ForwardMessageExecutor<string>("executor1"),
new ForwardMessageExecutor<string>("executor2"),
new ForwardMessageExecutor<string>("executor3")
]);
Func<object?, int, IEnumerable<int>>? assigner
= assignerSelectsEmpty.HasValue
@@ -150,10 +154,11 @@ public class EdgeRunnerTests
public async Task Test_FanInEdgeRunnerAsync()
{
TestRunContext runContext = new();
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
runContext.ConfigureExecutors([
new ForwardMessageExecutor<string>("executor1"),
new ForwardMessageExecutor<string>("executor2"),
new ForwardMessageExecutor<string>("executor3")
]);
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
FanInEdgeRunner runner = new(runContext, edgeData);
@@ -85,7 +85,7 @@ public class RepresentationTests
[Fact]
public async Task Test_SpecializedExecutor_InfosAsync()
{
await RunExecutorBindingInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent()));
await RunExecutorBindingInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent(), new()));
await RunExecutorBindingInfoMatchTestAsync(new RequestInfoExecutor(TestRequestPort));
}
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal sealed class RoleCheckAgent(bool allowOtherAssistantRoles, string? id = null, string? name = null) : AIAgent
{
protected override string? IdCore => id;
public override string? Name => name;
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new RoleCheckAgentThread());
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default) => new(new RoleCheckAgentThread());
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
foreach (ChatMessage message in messages)
{
if (!allowOtherAssistantRoles && message.Role == ChatRole.Assistant && !(message.AuthorName == null || message.AuthorName == this.Name))
{
throw new InvalidOperationException($"Message from other assistant role detected: AuthorName={message.AuthorName}");
}
}
yield return new AgentResponseUpdate(ChatRole.Assistant, "Ok")
{
AgentId = this.Id,
AuthorName = this.Name,
MessageId = Guid.NewGuid().ToString("N"),
ResponseId = Guid.NewGuid().ToString("N")
};
}
private sealed class RoleCheckAgentThread : InMemoryAgentThread;
}
@@ -2,9 +2,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
@@ -17,102 +14,6 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class SpecializedExecutorSmokeTests
{
public class TestAIAgent(List<ChatMessage>? messages = null, string? id = null, string? name = null) : AIAgent
{
protected override string? IdCore => id;
public override string? Name => name;
public static List<ChatMessage> ToChatMessages(params string[] messages)
{
List<ChatMessage> result = messages.Select(ToMessage).ToList();
static ChatMessage ToMessage(string text)
{
if (string.IsNullOrEmpty(text))
{
return new ChatMessage(ChatRole.Assistant, "") { MessageId = "" };
}
string[] splits = text.Split(' ');
for (int i = 0; i < splits.Length - 1; i++)
{
splits[i] += ' ';
}
List<AIContent> contents = splits.Select<string, AIContent>(text => new TextContent(text) { RawRepresentation = text }).ToList();
return new(ChatRole.Assistant, contents)
{
MessageId = Guid.NewGuid().ToString("N"),
RawRepresentation = text,
CreatedAt = DateTime.UtcNow,
};
}
return result;
}
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
=> new(new TestAgentThread());
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new TestAgentThread());
public static TestAIAgent FromStrings(params string[] messages) =>
new(ToChatMessages(messages));
public List<ChatMessage> Messages { get; } = Validate(messages) ?? [];
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
Task.FromResult(new AgentResponse(this.Messages)
{
AgentId = this.Id,
ResponseId = Guid.NewGuid().ToString("N")
});
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string responseId = Guid.NewGuid().ToString("N");
foreach (ChatMessage message in this.Messages)
{
foreach (AIContent content in message.Contents)
{
yield return new AgentResponseUpdate()
{
AgentId = this.Id,
MessageId = message.MessageId,
ResponseId = responseId,
Contents = [content],
Role = message.Role,
};
}
}
}
private static List<ChatMessage>? Validate(List<ChatMessage>? candidateMessages)
{
string? currentMessageId = null;
if (candidateMessages is not null)
{
foreach (ChatMessage message in candidateMessages)
{
if (currentMessageId is null)
{
currentMessageId = message.MessageId;
}
else if (currentMessageId == message.MessageId)
{
throw new ArgumentException("Duplicate consecutive message ids");
}
}
}
return candidateMessages;
}
}
public sealed class TestAgentThread() : InMemoryAgentThread();
internal sealed class TestWorkflowContext(string executorId, bool concurrentRunsEnabled = false) : IWorkflowContext
{
private readonly StateManager _stateManager = new();
@@ -177,10 +78,10 @@ public class SpecializedExecutorSmokeTests
"Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus."
];
List<ChatMessage> expected = TestAIAgent.ToChatMessages(MessageStrings);
List<ChatMessage> expected = TestReplayAgent.ToChatMessages(MessageStrings);
TestAIAgent agent = new(expected);
AIAgentHostExecutor host = new(agent);
TestReplayAgent agent = new(expected);
AIAgentHostExecutor host = new(agent, new());
TestWorkflowContext collectingContext = new(host.Id);
@@ -203,8 +104,8 @@ public class SpecializedExecutorSmokeTests
{
const string AgentAName = "TestAgentAName";
const string AgentBName = "TestAgentBName";
TestAIAgent agentA = new(name: AgentAName);
TestAIAgent agentB = new(name: AgentBName);
TestReplayAgent agentA = new(name: AgentAName);
TestReplayAgent agentB = new(name: AgentBName);
var workflow = new WorkflowBuilder(agentA).AddEdge(agentA, agentB).Build();
var definition = workflow.ToWorkflowInfo();
@@ -225,8 +126,8 @@ public class SpecializedExecutorSmokeTests
[Fact]
public async Task Test_AIAgent_ExecutorId_Use_Agent_ID_When_Name_Not_ProvidedAsync()
{
TestAIAgent agentA = new();
TestAIAgent agentB = new();
TestReplayAgent agentA = new();
TestReplayAgent agentB = new();
var workflow = new WorkflowBuilder(agentA).AddEdge(agentA, agentB).Build();
var definition = workflow.ToWorkflowInfo();
@@ -0,0 +1,105 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class TestReplayAgent(List<ChatMessage>? messages = null, string? id = null, string? name = null) : AIAgent
{
protected override string? IdCore => id;
public override string? Name => name;
public static List<ChatMessage> ToChatMessages(params string[] messages)
{
List<ChatMessage> result = messages.Select(ToMessage).ToList();
static ChatMessage ToMessage(string text)
{
if (string.IsNullOrEmpty(text))
{
return new ChatMessage(ChatRole.Assistant, "") { MessageId = "" };
}
string[] splits = text.Split(' ');
for (int i = 0; i < splits.Length - 1; i++)
{
splits[i] += ' ';
}
List<AIContent> contents = splits.Select<string, AIContent>(text => new TextContent(text) { RawRepresentation = text }).ToList();
return new(ChatRole.Assistant, contents)
{
MessageId = Guid.NewGuid().ToString("N"),
RawRepresentation = text,
CreatedAt = DateTime.UtcNow,
};
}
return result;
}
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
=> new(new ReplayAgentThread());
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new ReplayAgentThread());
public static TestReplayAgent FromStrings(params string[] messages) =>
new(ToChatMessages(messages));
public List<ChatMessage> Messages { get; } = Validate(messages) ?? [];
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string responseId = Guid.NewGuid().ToString("N");
foreach (ChatMessage message in this.Messages)
{
foreach (AIContent content in message.Contents)
{
yield return new AgentResponseUpdate()
{
AgentId = this.Id,
AuthorName = this.Name,
MessageId = message.MessageId,
ResponseId = responseId,
Contents = [content],
Role = message.Role,
};
}
}
}
private static List<ChatMessage>? Validate(List<ChatMessage>? candidateMessages)
{
string? currentMessageId = null;
if (candidateMessages is not null)
{
foreach (ChatMessage message in candidateMessages)
{
if (currentMessageId is null)
{
currentMessageId = message.MessageId;
}
else if (currentMessageId == message.MessageId)
{
throw new ArgumentException("Duplicate consecutive message ids");
}
}
}
return candidateMessages;
}
private sealed class ReplayAgentThread() : InMemoryAgentThread();
}
@@ -0,0 +1,378 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal sealed record TestRequestAgentThreadState(JsonElement ThreadState, Dictionary<string, PortableValue> UnservicedRequests, HashSet<string> ServicedRequests, HashSet<string> PairedRequests);
public enum TestAgentRequestType
{
FunctionCall,
UserInputRequest
}
internal sealed class TestRequestAgent(TestAgentRequestType requestType, int unpairedRequestCount, int pairedRequestCount, string? id, string? name) : AIAgent
{
public Random RNG { get; set; } = new Random(HashCode.Combine(requestType, nameof(TestRequestAgent)));
public AgentThread? LastThread { get; set; }
protected override string? IdCore => id;
public override string? Name => name;
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken)
=> new(requestType switch
{
TestAgentRequestType.FunctionCall => new TestRequestAgentThread<FunctionCallContent, FunctionResultContent>(),
TestAgentRequestType.UserInputRequest => new TestRequestAgentThread<UserInputRequestContent, UserInputResponseContent>(),
_ => throw new NotSupportedException(),
});
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(requestType switch
{
TestAgentRequestType.FunctionCall => new TestRequestAgentThread<FunctionCallContent, FunctionResultContent>(),
TestAgentRequestType.UserInputRequest => new TestRequestAgentThread<UserInputRequestContent, UserInputResponseContent>(),
_ => throw new NotSupportedException(),
});
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
private static int[] SampleIndicies(Random rng, int n, int c)
{
int[] result = Enumerable.Range(0, c).ToArray();
for (int i = c; i < n; i++)
{
int radix = rng.Next(i);
if (radix < c)
{
result[radix] = i;
}
}
return result;
}
private async IAsyncEnumerable<AgentResponseUpdate> RunStreamingAsync<TRequest, TResponse>(
IRequestResponseStrategy<TRequest, TResponse> strategy,
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
where TRequest : AIContent
where TResponse : AIContent
{
this.LastThread = thread ??= await this.GetNewThreadAsync(cancellationToken);
TestRequestAgentThread<TRequest, TResponse> traThread = ConvertThread<TRequest, TResponse>(thread);
if (traThread.HasSentRequests)
{
foreach (TResponse response in messages.SelectMany(message => message.Contents).OfType<TResponse>())
{
strategy.ProcessResponse(response, traThread);
}
if (traThread.UnservicedRequests.Count == 0)
{
yield return new(ChatRole.Assistant, "Done");
}
else
{
yield return new(ChatRole.Assistant, $"Remaining: {traThread.UnservicedRequests.Count}");
}
}
else
{
int totalRequestCount = unpairedRequestCount + pairedRequestCount;
yield return new(ChatRole.Assistant, $"Creating {totalRequestCount} requests, {pairedRequestCount} paired.");
HashSet<int> servicedIndicies = [.. SampleIndicies(this.RNG, totalRequestCount, pairedRequestCount)];
(string, TRequest)[] requests = strategy.CreateRequests(unpairedRequestCount + pairedRequestCount).ToArray();
List<AIContent> pairedResponses = new(capacity: pairedRequestCount);
for (int i = 0; i < requests.Length; i++)
{
(string id, TRequest request) = requests[i];
if (servicedIndicies.Contains(i))
{
traThread.PairedRequests.Add(id);
pairedResponses.Add(strategy.CreatePairedResponse(request));
}
else
{
traThread.UnservicedRequests.Add(id, request);
}
yield return new(ChatRole.Assistant, [request]);
}
yield return new(ChatRole.Assistant, pairedResponses);
traThread.HasSentRequests = true;
}
}
private static TestRequestAgentThread<TRequest, TResponse> ConvertThread<TRequest, TResponse>(AgentThread thread)
where TRequest : AIContent
where TResponse : AIContent
{
if (thread is not TestRequestAgentThread<TRequest, TResponse> traThread)
{
throw new ArgumentException($"Bad AgentThread type: Expected {typeof(TestRequestAgentThread<TRequest, TResponse>)}, got {thread.GetType()}.", nameof(thread));
}
return traThread;
}
private sealed class FunctionCallStrategy : IRequestResponseStrategy<FunctionCallContent, FunctionResultContent>
{
public FunctionResultContent CreatePairedResponse(FunctionCallContent request)
{
return new FunctionResultContent(request.CallId, request);
}
public IEnumerable<(string, FunctionCallContent)> CreateRequests(int count)
{
for (int i = 0; i < count; i++)
{
string callId = Guid.NewGuid().ToString("N");
FunctionCallContent request = new(callId, "TestFunction");
yield return (callId, request);
}
}
public void ProcessResponse(FunctionResultContent response, TestRequestAgentThread<FunctionCallContent, FunctionResultContent> thread)
{
if (thread.UnservicedRequests.TryGetValue(response.CallId, out FunctionCallContent? request))
{
response.Result.As<FunctionCallContent>().Should().Be(request);
thread.ServicedRequests.Add(response.CallId);
thread.UnservicedRequests.Remove(response.CallId);
}
else if (thread.ServicedRequests.Contains(response.CallId))
{
throw new InvalidOperationException($"Seeing duplicate response with id {response.CallId}");
}
else if (thread.PairedRequests.Contains(response.CallId))
{
throw new InvalidOperationException($"Seeing explicit response to initially paired request with id {response.CallId}");
}
else
{
throw new InvalidOperationException($"Seeing response to nonexistent request with id {response.CallId}");
}
}
}
private sealed class FunctionApprovalStrategy : IRequestResponseStrategy<UserInputRequestContent, UserInputResponseContent>
{
public UserInputResponseContent CreatePairedResponse(UserInputRequestContent request)
{
if (request is not FunctionApprovalRequestContent approvalRequest)
{
throw new InvalidOperationException($"Invalid request: Expecting {typeof(FunctionApprovalResponseContent)}, got {request.GetType()}");
}
return new FunctionApprovalResponseContent(approvalRequest.Id, true, approvalRequest.FunctionCall);
}
public IEnumerable<(string, UserInputRequestContent)> CreateRequests(int count)
{
for (int i = 0; i < count; i++)
{
string id = Guid.NewGuid().ToString("N");
UserInputRequestContent request = new FunctionApprovalRequestContent(id, new(id, "TestFunction"));
yield return (id, request);
}
}
public void ProcessResponse(UserInputResponseContent response, TestRequestAgentThread<UserInputRequestContent, UserInputResponseContent> thread)
{
if (thread.UnservicedRequests.TryGetValue(response.Id, out UserInputRequestContent? request))
{
if (request is not FunctionApprovalRequestContent approvalRequest)
{
throw new InvalidOperationException($"Invalid request: Expecting {typeof(FunctionApprovalResponseContent)}, got {request.GetType()}");
}
if (response is not FunctionApprovalResponseContent approvalResponse)
{
throw new InvalidOperationException($"Invalid response: Expecting {typeof(FunctionApprovalResponseContent)}, got {response.GetType()}");
}
approvalResponse.Approved.Should().BeTrue();
approvalResponse.FunctionCall.As<FunctionCallContent>().Should().Be(approvalRequest.FunctionCall);
thread.ServicedRequests.Add(response.Id);
thread.UnservicedRequests.Remove(response.Id);
}
else if (thread.ServicedRequests.Contains(response.Id))
{
throw new InvalidOperationException($"Seeing duplicate response with id {response.Id}");
}
else if (thread.PairedRequests.Contains(response.Id))
{
throw new InvalidOperationException($"Seeing explicit response to initially paired request with id {response.Id}");
}
else
{
throw new InvalidOperationException($"Seeing response to nonexistent request with id {response.Id}");
}
}
}
private interface IRequestResponseStrategy<TRequest, TResponse>
where TRequest : AIContent
where TResponse : AIContent
{
IEnumerable<(string, TRequest)> CreateRequests(int count);
TResponse CreatePairedResponse(TRequest request);
void ProcessResponse(TResponse response, TestRequestAgentThread<TRequest, TResponse> thread);
}
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return requestType switch
{
TestAgentRequestType.FunctionCall => this.RunStreamingAsync(new FunctionCallStrategy(), messages, thread, options, cancellationToken),
TestAgentRequestType.UserInputRequest => this.RunStreamingAsync(new FunctionApprovalStrategy(), messages, thread, options, cancellationToken),
_ => throw new NotSupportedException($"Unknown AgentRequestType {requestType}"),
};
}
private static string RetrieveId<TRequest>(TRequest request)
where TRequest : AIContent
{
return request switch
{
FunctionCallContent functionCall => functionCall.CallId,
UserInputRequestContent userInputRequest => userInputRequest.Id,
_ => throw new NotSupportedException($"Unknown request type {typeof(TRequest)}"),
};
}
private IEnumerable<TResponse> ValidateUnpairedRequests<TRequest, TResponse>(IEnumerable<TRequest> requests, IRequestResponseStrategy<TRequest, TResponse> strategy)
where TRequest : AIContent
where TResponse : AIContent
{
this.LastThread.Should().NotBeNull();
TestRequestAgentThread<TRequest, TResponse> traThread = ConvertThread<TRequest, TResponse>(this.LastThread);
requests.Should().HaveCount(traThread.UnservicedRequests.Count);
foreach (TRequest request in requests)
{
string requestId = RetrieveId(request);
traThread.UnservicedRequests.Should().ContainKey(requestId);
yield return strategy.CreatePairedResponse(request);
}
}
internal IEnumerable<object> ValidateUnpairedRequests<TRequest>(IEnumerable<TRequest> requests)
where TRequest : AIContent
{
switch (requestType)
{
case TestAgentRequestType.FunctionCall:
if (typeof(TRequest) != typeof(FunctionCallContent))
{
throw new ArgumentException($"Invalid request type: Expected {typeof(FunctionCallContent)}, got {typeof(TRequest)}", nameof(requests));
}
return this.ValidateUnpairedRequests((IEnumerable<FunctionCallContent>)requests, new FunctionCallStrategy());
case TestAgentRequestType.UserInputRequest:
if (!typeof(UserInputRequestContent).IsAssignableFrom(typeof(TRequest)))
{
throw new ArgumentException($"Invalid request type: Expected {typeof(UserInputRequestContent)}, got {typeof(TRequest)}", nameof(requests));
}
return this.ValidateUnpairedRequests((IEnumerable<UserInputRequestContent>)requests, new FunctionApprovalStrategy());
default:
throw new NotSupportedException($"Unknown AgentRequestType {requestType}");
}
}
internal IEnumerable<ExternalResponse> ValidateUnpairedRequests(List<ExternalRequest> requests)
{
List<object> responses;
switch (requestType)
{
case TestAgentRequestType.FunctionCall:
responses = this.ValidateUnpairedRequests(requests.Select(AssertAndExtractRequestContent<FunctionCallContent>)).ToList();
break;
case TestAgentRequestType.UserInputRequest:
responses = this.ValidateUnpairedRequests(requests.Select(AssertAndExtractRequestContent<UserInputRequestContent>)).ToList();
break;
default:
throw new NotSupportedException($"Unknown AgentRequestType {requestType}");
}
return Enumerable.Zip(requests, responses, (ExternalRequest request, object response) => request.CreateResponse(response));
static TRequest AssertAndExtractRequestContent<TRequest>(ExternalRequest request)
{
request.DataIs(out TRequest? content).Should().BeTrue();
return content!;
}
}
private sealed class TestRequestAgentThread<TRequest, TResponse> : InMemoryAgentThread
where TRequest : AIContent
where TResponse : AIContent
{
public TestRequestAgentThread()
{
}
public bool HasSentRequests { get; set; }
public Dictionary<string, TRequest> UnservicedRequests { get; } = new();
public HashSet<string> ServicedRequests { get; } = new();
public HashSet<string> PairedRequests { get; } = new();
private static JsonElement DeserializeAndExtractState(JsonElement serializedState,
out TestRequestAgentThreadState state,
JsonSerializerOptions? jsonSerializerOptions = null)
{
state = JsonSerializer.Deserialize<TestRequestAgentThreadState>(serializedState, jsonSerializerOptions)
?? throw new ArgumentException("Unable to deserialize thread state.");
return state.ThreadState;
}
public TestRequestAgentThread(JsonElement element, JsonSerializerOptions? jsonSerializerOptions = null)
: base(DeserializeAndExtractState(element, out TestRequestAgentThreadState state, jsonSerializerOptions))
{
this.UnservicedRequests = state.UnservicedRequests.ToDictionary(
keySelector: item => item.Key,
elementSelector: item => item.Value.As<TRequest>()!);
this.ServicedRequests = state.ServicedRequests;
this.PairedRequests = state.PairedRequests;
}
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
JsonElement threadState = base.Serialize(jsonSerializerOptions);
Dictionary<string, PortableValue> portableUnservicedRequests =
this.UnservicedRequests.ToDictionary(
keySelector: item => item.Key,
elementSelector: item => new PortableValue(item.Value));
TestRequestAgentThreadState state = new(threadState, portableUnservicedRequests, this.ServicedRequests, this.PairedRequests);
return JsonSerializer.SerializeToElement(state, jsonSerializerOptions);
}
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -10,6 +11,36 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class TestRunContext : IRunnerContext
{
private sealed class TestExternalRequestContext(IRunnerContext runnerContext, string executorId, EdgeMap? map) : IExternalRequestContext
{
public IExternalRequestSink RegisterPort(RequestPort port)
{
if (map?.TryRegisterPort(runnerContext, executorId, port) == false)
{
throw new InvalidOperationException("Duplicate port id: " + port.Id);
}
return runnerContext;
}
}
internal TestRunContext ConfigureExecutor(Executor executor, EdgeMap? map = null)
{
executor.Configure(new TestExternalRequestContext(this, executor.Id, map));
this.Executors.Add(executor.Id, executor);
return this;
}
internal TestRunContext ConfigureExecutors(IEnumerable<Executor> executors, EdgeMap? map = null)
{
foreach (var executor in executors)
{
this.ConfigureExecutor(executor, map);
}
return this;
}
private sealed class BoundContext(
string executorId,
TestRunContext runnerContext,
@@ -57,13 +88,13 @@ public class TestRunContext : IRunnerContext
return default;
}
public IWorkflowContext Bind(string executorId, Dictionary<string, string>? traceContext = null)
public IWorkflowContext BindWorkflowContext(string executorId, Dictionary<string, string>? traceContext = null)
=> new BoundContext(executorId, this, traceContext);
public List<ExternalRequest> ExternalRequests { get; } = [];
public ConcurrentQueue<ExternalRequest> ExternalRequests { get; } = [];
public ValueTask PostAsync(ExternalRequest request)
{
this.ExternalRequests.Add(request);
this.ExternalRequests.Enqueue(request);
return default;
}
@@ -99,8 +130,8 @@ public class TestRunContext : IRunnerContext
public Dictionary<string, Executor> Executors { get; set; } = [];
public string StartingExecutorId { get; set; } = string.Empty;
public bool WithCheckpointing => throw new NotSupportedException();
public bool ConcurrentRunsEnabled => throw new NotSupportedException();
public bool WithCheckpointing => false;
public bool ConcurrentRunsEnabled => false;
ValueTask<Executor> IRunnerContext.EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken) =>
new(this.Executors[executorId]);