feat: Solidify Concurrency Model for Inproc Workflows (#925)

* Disallow execution of multiple workflows at once
* Define notion of executor Reset() to allow reuse of workflows with shared executor instances
* Switch to delivering all messages to a single executor sequentially, with executors running in parallel
This commit is contained in:
Jacob Alber
2025-09-26 11:44:02 -04:00
committed by GitHub
Unverified
parent eec7f192eb
commit 9614eea875
35 changed files with 773 additions and 369 deletions
@@ -145,7 +145,7 @@ public static partial class AgentWorkflowBuilder
/// <summary>
/// Executor that runs the agent and forwards all messages, input and output, to the next executor.
/// </summary>
private sealed class AgentRunStreamingExecutor(AIAgent agent, bool includeInputInOutput) : Executor(GetDescriptiveIdFromAgent(agent))
private sealed class AgentRunStreamingExecutor(AIAgent agent, bool includeInputInOutput) : Executor(GetDescriptiveIdFromAgent(agent)), IResettableExecutor
{
private readonly List<ChatMessage> _pendingMessages = [];
@@ -185,20 +185,28 @@ public static partial class AgentWorkflowBuilder
await context.SendMessageAsync(messages).ConfigureAwait(false);
await context.SendMessageAsync(token).ConfigureAwait(false);
});
public ValueTask ResetAsync()
{
this._pendingMessages.Clear();
return default;
}
}
/// <summary>
/// Provides an executor that batches received chat messages that it then publishes as the final result
/// when receiving a <see cref="TurnToken"/>.
/// </summary>
private sealed class OutputMessagesExecutor() : ChatProtocolExecutor("OutputMessages")
private sealed class OutputMessagesExecutor() : ChatProtocolExecutor("OutputMessages"), IResettableExecutor
{
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellation = default)
=> context.YieldOutputAsync(messages);
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
}
/// <summary>Executor that forwards all messages.</summary>
private sealed class ChatForwardingExecutor(string id) : Executor(id)
private sealed class ChatForwardingExecutor(string id) : Executor(id), IResettableExecutor
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
@@ -206,23 +214,27 @@ public static partial class AgentWorkflowBuilder
.AddHandler<ChatMessage>((message, context) => context.SendMessageAsync(message))
.AddHandler<List<ChatMessage>>((messages, context) => context.SendMessageAsync(messages))
.AddHandler<TurnToken>((turnToken, context) => context.SendMessageAsync(turnToken));
public ValueTask ResetAsync() => default;
}
/// <summary>
/// Provides an executor that batches received chat messages that it then releases when
/// receiving a <see cref="TurnToken"/>.
/// </summary>
private sealed class BatchChatMessagesToListExecutor(string id) : ChatProtocolExecutor(id)
private sealed class BatchChatMessagesToListExecutor(string id) : ChatProtocolExecutor(id), IResettableExecutor
{
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellation = default)
=> context.SendMessageAsync(messages);
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
}
/// <summary>
/// Provides an executor that accepts the output messages from each of the concurrent agents
/// and produces a result list containing the last message from each.
/// </summary>
private sealed class ConcurrentEndExecutor : Executor
private sealed class ConcurrentEndExecutor : Executor, IResettableExecutor
{
private readonly int _expectedInputs;
private readonly Func<IList<List<ChatMessage>>, List<ChatMessage>> _aggregator;
@@ -238,6 +250,12 @@ public static partial class AgentWorkflowBuilder
this._remaining = expectedInputs;
}
private void Reset()
{
this._allResults = new List<List<ChatMessage>>(this._expectedInputs);
this._remaining = this._expectedInputs;
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<List<ChatMessage>>(async (messages, context) =>
{
@@ -259,6 +277,12 @@ public static partial class AgentWorkflowBuilder
await context.YieldOutputAsync(this._aggregator(results)).ConfigureAwait(false);
}
});
public ValueTask ResetAsync()
{
this.Reset();
return default;
}
}
/// <summary>
@@ -428,7 +452,7 @@ public static partial class AgentWorkflowBuilder
}
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
private sealed class StartHandoffsExecutor() : Executor("HandoffStart")
private sealed class StartHandoffsExecutor() : Executor("HandoffStart"), IResettableExecutor
{
private readonly List<ChatMessage> _pendingMessages = [];
@@ -445,20 +469,28 @@ public static partial class AgentWorkflowBuilder
this._pendingMessages.Clear();
await context.SendMessageAsync(new HandoffState(token, null, messages)).ConfigureAwait(false);
});
public ValueTask ResetAsync()
{
this._pendingMessages.Clear();
return default;
}
}
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
private sealed class EndHandoffsExecutor() : Executor("HandoffEnd")
private sealed class EndHandoffsExecutor() : Executor("HandoffEnd"), IResettableExecutor
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<HandoffState>((handoff, context) =>
context.YieldOutputAsync(handoff.Messages));
public ValueTask ResetAsync() => default;
}
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
private sealed class HandoffAgentExecutor(
AIAgent agent,
string? handoffInstructions) : Executor(GetDescriptiveIdFromAgent(agent))
string? handoffInstructions) : Executor(GetDescriptiveIdFromAgent(agent)), IResettableExecutor
{
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
@@ -548,6 +580,8 @@ public static partial class AgentWorkflowBuilder
}
}
});
public ValueTask ResetAsync() => default;
}
private record class HandoffState(
@@ -746,7 +780,7 @@ public static partial class AgentWorkflowBuilder
return builder.WithOutputFrom(host).Build();
}
private sealed class GroupChatHost(AIAgent[] agents, Dictionary<AIAgent, ExecutorIsh> agentMap, Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) : Executor("GroupChatHost")
private sealed class GroupChatHost(AIAgent[] agents, Dictionary<AIAgent, ExecutorIsh> agentMap, Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) : Executor("GroupChatHost"), IResettableExecutor
{
private readonly AIAgent[] _agents = agents;
private readonly Dictionary<AIAgent, ExecutorIsh> _agentMap = agentMap;
@@ -786,6 +820,14 @@ public static partial class AgentWorkflowBuilder
this._manager = null;
await context.YieldOutputAsync(messages).ConfigureAwait(false);
});
public ValueTask ResetAsync()
{
this._pendingMessages.Clear();
this._manager = null;
return default;
}
}
}
@@ -18,6 +18,14 @@ internal abstract class ChatProtocolExecutor(string id, ChatProtocolExecutorOpti
private List<ChatMessage> _pendingMessages = [];
private readonly ChatRole? _stringMessageChatRole = options?.StringMessageChatRole;
// Note that we explicitly do not implement IResettableExecutor here, as we want to allow derived classes to
// implement it if they want to be resettable, but do not want to opt them into it.
protected ValueTask ResetAsync()
{
this._pendingMessages = [];
return default;
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
if (this._stringMessageChatRole.HasValue)
@@ -9,13 +9,15 @@ internal sealed class PortableMessageEnvelope
{
public TypeId MessageType { get; }
public PortableValue Message { get; }
public ExecutorIdentity Source { get; }
public string? TargetId { get; }
[JsonConstructor]
internal PortableMessageEnvelope(TypeId messageType, PortableValue message, string? targetId)
internal PortableMessageEnvelope(TypeId messageType, ExecutorIdentity source, PortableValue message, string? targetId)
{
this.MessageType = messageType;
this.Message = message;
this.Source = source;
this.TargetId = targetId;
}
@@ -28,6 +30,6 @@ internal sealed class PortableMessageEnvelope
public MessageEnvelope ToMessageEnvelope()
{
return new MessageEnvelope(this.Message, this.MessageType, this.TargetId);
return new MessageEnvelope(this.Message, this.Source, this.MessageType, this.TargetId);
}
}
@@ -13,8 +13,8 @@ namespace Microsoft.Agents.Workflows.Checkpointing;
/// at the time of initial deserialization, e.g. user-defined state types.
///
/// This operates in conjuction with <see cref="IDelayedDeserialization"/> and <see cref="PortableValue"/> to abstract
/// away the speicfics of a given serialization format in favor of <see cref="PortableValue.As{TValue}"/> and
/// <see cref="PortableValue.Is{TValue}"/>.
/// away the speicfics of a given serialization format in favor of <see cref="PortableValue.As{TValue}()"/> and
/// <see cref="PortableValue.Is{TValue}()"/> and related methods.
/// </summary>
/// <param name="marshaller"></param>
internal sealed class PortableValueConverter(JsonMarshaller marshaller) : JsonConverter<PortableValue>
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Workflows.Execution;
internal sealed class DeliveryMapping
{
private readonly IEnumerable<MessageEnvelope> _envelopes;
private readonly IEnumerable<Executor> _targets;
public DeliveryMapping(IEnumerable<MessageEnvelope> envelopes, IEnumerable<Executor> targets)
{
this._envelopes = Throw.IfNull(envelopes);
this._targets = Throw.IfNull(targets);
}
public DeliveryMapping(MessageEnvelope envelope, Executor target) : this([envelope], [target]) { }
public DeliveryMapping(MessageEnvelope envelope, IEnumerable<Executor> targets) : this([envelope], targets) { }
public DeliveryMapping(IEnumerable<MessageEnvelope> envelopes, Executor target) : this(envelopes, [target]) { }
public IEnumerable<MessageDelivery> Deliveries => from target in this._targets
from envelope in this._envelopes
select new MessageDelivery(envelope, target);
public void MapInto(StepContext nextStep)
{
foreach (Executor target in this._targets)
{
nextStep.MessagesFor(target.Id).AddRange(this._envelopes.Select(envelope => envelope));
}
}
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Microsoft.Agents.Workflows.Execution;
@@ -13,26 +12,25 @@ internal sealed class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData
private async ValueTask<Executor> FindRouterAsync(IStepTracer? tracer) => await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId, tracer)
.ConfigureAwait(false);
public async ValueTask<IEnumerable<object?>> ChaseAsync(MessageEnvelope envelope, IStepTracer? tracer)
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
{
if (envelope.TargetId is not null && this.EdgeData.SinkId != envelope.TargetId)
{
return [];
return null;
}
object message = envelope.Message;
if (this.EdgeData.Condition is not null && !this.EdgeData.Condition(message))
{
return [];
return null;
}
Executor target = await this.FindRouterAsync(tracer).ConfigureAwait(false);
Executor target = await this.FindRouterAsync(stepTracer).ConfigureAwait(false);
if (target.CanHandle(envelope.MessageType))
{
tracer?.TraceActivated(target.Id);
return [await target.ExecuteAsync(message, envelope.MessageType, this.WorkflowContext).ConfigureAwait(false)];
return new DeliveryMapping(envelope, target);
}
return [];
return null;
}
}
@@ -10,12 +10,23 @@ namespace Microsoft.Agents.Workflows.Execution;
internal sealed class EdgeMap
{
private readonly Dictionary<EdgeId, object> _edgeRunners = [];
private readonly Dictionary<EdgeId, FanInEdgeState> _fanInState = [];
private readonly Dictionary<EdgeId, EdgeRunner> _edgeRunners = [];
private readonly Dictionary<EdgeId, IStatefulEdgeRunner> _statefulRunners = new();
private readonly Dictionary<string, InputEdgeRunner> _portEdgeRunners;
private readonly InputEdgeRunner _inputRunner;
private readonly IStepTracer? _stepTracer;
public EdgeMap(IRunnerContext runContext,
Workflow workflow,
IStepTracer? stepTracer)
: this(runContext,
workflow.Edges,
workflow.Ports.Values,
workflow.StartExecutorId,
stepTracer)
{ }
public EdgeMap(IRunnerContext runContext,
Dictionary<string, HashSet<Edge>> workflowEdges,
IEnumerable<InputPort> workflowPorts,
@@ -24,7 +35,7 @@ internal sealed class EdgeMap
{
foreach (Edge edge in workflowEdges.Values.SelectMany(e => e))
{
object edgeRunner = edge.Kind switch
EdgeRunner edgeRunner = edge.Kind switch
{
EdgeKind.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!),
EdgeKind.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!),
@@ -32,12 +43,12 @@ internal sealed class EdgeMap
_ => throw new NotSupportedException($"Unsupported edge type: {edge.Kind}")
};
if (edgeRunner is FanInEdgeRunner fanInRunner)
{
this._fanInState[edge.Data.Id] = fanInRunner.CreateState();
}
this._edgeRunners[edge.Data.Id] = edgeRunner;
if (edgeRunner is IStatefulEdgeRunner statefulRunner)
{
this._statefulRunners[edge.Data.Id] = statefulRunner;
}
}
this._portEdgeRunners = workflowPorts.ToDictionary(
@@ -49,103 +60,52 @@ internal sealed class EdgeMap
this._stepTracer = stepTracer;
}
public async ValueTask<IEnumerable<object?>> InvokeEdgeAsync(Edge edge, string sourceId, MessageEnvelope message)
public ValueTask<DeliveryMapping?> PrepareDeliveryForEdgeAsync(Edge edge, MessageEnvelope message)
{
EdgeId id = edge.Data.Id;
if (!this._edgeRunners.TryGetValue(id, out object? edgeRunner))
if (!this._edgeRunners.TryGetValue(id, out EdgeRunner? edgeRunner))
{
throw new InvalidOperationException($"Edge {edge} not found in the edge map.");
}
IEnumerable<object?> edgeResults;
switch (edge.Kind)
{
// We know the corresponding EdgeRunner type given the FlowEdge EdgeType, as
// established in the EdgeMap() ctor; this avoid doing an as-cast inside of
// the depths of the message delivery loop for every edges (multiplicity N,
// in FanIn/Out cases)
// TODO: Once we have a fixed interface, if it is reasonably generalizable
// between the Runners, we can normalize it behind an IFace.
case EdgeKind.Direct:
{
DirectEdgeRunner runner = (DirectEdgeRunner)edgeRunner;
edgeResults = await runner.ChaseAsync(message, this._stepTracer).ConfigureAwait(false);
break;
}
case EdgeKind.FanOut:
{
FanOutEdgeRunner runner = (FanOutEdgeRunner)edgeRunner;
edgeResults = await runner.ChaseAsync(message, this._stepTracer).ConfigureAwait(false);
break;
}
case EdgeKind.FanIn:
{
FanInEdgeState state = this._fanInState[id];
FanInEdgeRunner runner = (FanInEdgeRunner)edgeRunner;
edgeResults = [await runner.ChaseAsync(sourceId, message, state, this._stepTracer).ConfigureAwait(false)];
break;
}
default:
throw new InvalidOperationException("Unknown edge type");
}
return edgeResults;
return edgeRunner.ChaseEdgeAsync(message, this._stepTracer);
}
// TODO: Should we promote Input to a true "FlowEdge" type?
public async ValueTask<IEnumerable<object?>> InvokeInputAsync(MessageEnvelope envelope)
public ValueTask<DeliveryMapping?> PrepareDeliveryForInputAsync(MessageEnvelope message)
{
return [await this._inputRunner.ChaseAsync(envelope, this._stepTracer).ConfigureAwait(false)];
return this._inputRunner.ChaseEdgeAsync(message, this._stepTracer);
}
public async ValueTask<IEnumerable<object?>> InvokeResponseAsync(ExternalResponse response)
public ValueTask<DeliveryMapping?> PrepareDeliveryForResponseAsync(ExternalResponse response)
{
if (!this._portEdgeRunners.TryGetValue(response.PortInfo.PortId, out InputEdgeRunner? portRunner))
{
throw new InvalidOperationException($"Port {response.PortInfo.PortId} not found in the edge map.");
}
return [await portRunner.ChaseAsync(new MessageEnvelope(response), this._stepTracer).ConfigureAwait(false)];
return portRunner.ChaseEdgeAsync(new MessageEnvelope(response, ExecutorIdentity.None), this._stepTracer);
}
internal ValueTask<Dictionary<EdgeId, PortableValue>> ExportStateAsync()
internal async ValueTask<Dictionary<EdgeId, PortableValue>> ExportStateAsync()
{
Dictionary<EdgeId, PortableValue> exportedStates = [];
// Right now there is only fan-in state
foreach (EdgeId id in this._fanInState.Keys)
foreach (EdgeId id in this._statefulRunners.Keys)
{
FanInEdgeState state = this._fanInState[id];
exportedStates[id] = new PortableValue(state);
exportedStates[id] = await this._statefulRunners[id].ExportStateAsync().ConfigureAwait(false);
}
return new(exportedStates);
return exportedStates;
}
internal ValueTask ImportStateAsync(Checkpoint checkpoint)
internal async ValueTask ImportStateAsync(Checkpoint checkpoint)
{
Dictionary<EdgeId, PortableValue> importedState = checkpoint.EdgeStateData;
this._fanInState.Clear();
foreach (EdgeId id in importedState.Keys)
{
PortableValue exportedState = importedState[id];
FanInEdgeState? fanInState = exportedState.As<FanInEdgeState>();
if (fanInState is not null)
{
this._fanInState[id] = fanInState;
}
else
{
throw new InvalidOperationException($"Unsupported exported state type: {exportedState.GetType()}; {id}");
}
await this._statefulRunners[id].ImportStateAsync(exportedState).ConfigureAwait(false);
}
return default;
}
}
@@ -1,11 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Workflows.Execution;
internal interface IStatefulEdgeRunner
{
ValueTask<PortableValue> ExportStateAsync();
ValueTask ImportStateAsync(PortableValue state);
}
internal abstract class EdgeRunner
{
// TODO: Can this be sync?
protected internal abstract ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer);
}
internal abstract class EdgeRunner<TEdgeData>(
IRunnerContext runContext, TEdgeData edgeData)
IRunnerContext runContext, TEdgeData edgeData) : EdgeRunner()
{
protected IRunnerContext RunContext { get; } = Throw.IfNull(runContext);
protected TEdgeData EdgeData { get; } = Throw.IfNull(edgeData);
@@ -1,58 +1,56 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
namespace Microsoft.Agents.Workflows.Execution;
internal sealed class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData) :
EdgeRunner<FanInEdgeData>(runContext, edgeData)
EdgeRunner<FanInEdgeData>(runContext, edgeData),
IStatefulEdgeRunner
{
private IWorkflowContext BoundContext { get; } = runContext.Bind(edgeData.SinkId);
private FanInEdgeState _state = new(edgeData);
public FanInEdgeState CreateState() => new(this.EdgeData);
public ValueTask<IEnumerable<object?>> ChaseAsync(string sourceId, MessageEnvelope envelope, FanInEdgeState state, IStepTracer? tracer)
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
{
Debug.Assert(!envelope.IsExternal, "FanIn edges should never be chased from external input");
if (envelope.TargetId is not null && this.EdgeData.SinkId != envelope.TargetId)
{
// This message is not for us.
return new([]);
return null;
}
IEnumerable<MessageEnvelope>? releasedMessages = state.ProcessMessage(sourceId, envelope);
// source.Id is guaranteed to be non-null here because source is not None.
IEnumerable<MessageEnvelope>? releasedMessages = this._state.ProcessMessage(envelope.SourceId, envelope);
if (releasedMessages is null)
{
// Not ready to process yet.
return new([]);
return null;
}
return this.ForwardReleasedMessagesAsync(releasedMessages, tracer);
}
private async ValueTask<IEnumerable<object?>> ForwardReleasedMessagesAsync(IEnumerable<MessageEnvelope> releasedMessages, IStepTracer? tracer)
{
Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId, tracer)
// TODO: Filter messages based on accepted input types?
Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId, stepTracer)
.ConfigureAwait(false);
List<Task<object?>> messageTasks = [];
return new DeliveryMapping(releasedMessages.Where(envelope => target.CanHandle(envelope.MessageType)), target);
}
foreach (MessageEnvelope releasedEnvelope in releasedMessages)
public ValueTask<PortableValue> ExportStateAsync()
{
return new(new PortableValue(this._state));
}
public ValueTask ImportStateAsync(PortableValue state)
{
if (state.Is(out FanInEdgeState? importedState))
{
object message = releasedEnvelope.Message;
Debug.Assert(message is PortableValue, "It should not be possible to get messages released without roundtripping them through" +
"PortableValue via PortableMessageEnvelope.");
PortableValue portable = message as PortableValue ?? new PortableValue(releasedEnvelope.MessageType, message);
if (target.CanHandle(portable.TypeId))
{
tracer?.TraceActivated(target.Id);
messageTasks.Add(target.ExecuteAsync(portable, releasedEnvelope.MessageType, this.BoundContext).AsTask());
}
this._state = importedState;
return default;
}
return await Task.WhenAll(messageTasks.ToArray()).ConfigureAwait(false);
throw new InvalidOperationException($"Unsupported exported state type: {state.GetType()}; {this.EdgeData.Id}");
}
}
@@ -14,38 +14,28 @@ internal sealed class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData
sinkId => sinkId,
runContext.Bind);
public async ValueTask<IEnumerable<object?>> ChaseAsync(MessageEnvelope envelope, IStepTracer? tracer)
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
{
object message = envelope.Message;
List<string> targets =
IEnumerable<string> targetIds =
this.EdgeData.EdgeAssigner is null
? this.EdgeData.SinkIds
: this.EdgeData.EdgeAssigner(message, this.BoundContexts.Count)
.Select(i => this.EdgeData.SinkIds[i]).ToList();
.Select(i => this.EdgeData.SinkIds[i]);
IEnumerable<string> filteredTargets =
envelope.TargetId is not null
? targets.Where(IsValidTarget)
: targets;
Executor[] result = await Task.WhenAll(targetIds.Where(IsValidTarget)
.Select(tid => this.RunContext.EnsureExecutorAsync(tid, stepTracer)
.AsTask()))
.ConfigureAwait(false);
object?[] result = await Task.WhenAll(filteredTargets.Select(ProcessTargetAsync)).ConfigureAwait(false);
return result.Where(r => r is not null);
async Task<object?> ProcessTargetAsync(string targetId)
if (result.Length == 0)
{
Executor executor = await this.RunContext.EnsureExecutorAsync(targetId, tracer)
.ConfigureAwait(false);
if (executor.CanHandle(message.GetType()))
{
tracer?.TraceActivated(executor.Id);
return await executor.ExecuteAsync(message, envelope.MessageType, this.BoundContexts[targetId])
.ConfigureAwait(false);
}
return null;
}
IEnumerable<Executor> validTargets = result.Where(t => t.CanHandle(envelope.MessageType));
return new DeliveryMapping(envelope, validTargets);
bool IsValidTarget(string targetId)
{
return envelope.TargetId is null || targetId == envelope.TargetId;
@@ -9,7 +9,7 @@ internal interface IRunnerContext : IExternalRequestSink
ValueTask AddEventAsync(WorkflowEvent workflowEvent);
ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null);
StepContext Advance();
ValueTask<StepContext> AdvanceAsync();
IWorkflowContext Bind(string executorId);
ValueTask<Executor> EnsureExecutorAsync(string executorId, IStepTracer? tracer);
}
@@ -19,4 +19,6 @@ internal interface ISuperStepRunner
event EventHandler<WorkflowEvent>? WorkflowEvent;
ValueTask<bool> RunSuperStepAsync(CancellationToken cancellation);
ValueTask RequestEndRunAsync();
}
@@ -19,21 +19,17 @@ internal sealed class InputEdgeRunner(IRunnerContext runContext, string sinkId)
return new InputEdgeRunner(runContext, port.Id);
}
private async ValueTask<Executor> FindExecutorAsync(IStepTracer? tracer) => await this.RunContext.EnsureExecutorAsync(this.EdgeData, tracer).ConfigureAwait(false);
public async ValueTask<object?> ChaseAsync(MessageEnvelope envelope, IStepTracer? tracer)
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
{
Executor target = await this.FindExecutorAsync(tracer).ConfigureAwait(false);
Debug.Assert(envelope.IsExternal, "Input edges should only be chased from external input");
Executor target = await this.FindExecutorAsync(stepTracer).ConfigureAwait(false);
if (target.CanHandle(envelope.MessageType))
{
tracer?.TraceActivated(target.Id);
return await target.ExecuteAsync(envelope.Message, envelope.MessageType, this.WorkflowContext)
.ConfigureAwait(false);
return new DeliveryMapping(envelope, target);
}
// TODO: Throw instead? / Log
Debug.WriteLine($"Executor {target.Id} cannot handle message of type {envelope.MessageType.TypeName}. Dropping.");
return null;
}
private async ValueTask<Executor> FindExecutorAsync(IStepTracer? tracer) => await this.RunContext.EnsureExecutorAsync(this.EdgeData, tracer).ConfigureAwait(false);
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Workflows.Execution;
internal sealed class MessageDelivery
{
[JsonConstructor]
internal MessageDelivery(MessageEnvelope envelope, string targetId)
{
this.Envelope = Throw.IfNull(envelope);
this.TargetId = Throw.IfNull(targetId);
}
internal MessageDelivery(MessageEnvelope envelope, Executor target)
: this(envelope, target.Id)
{
this.TargetCache = Throw.IfNull(target);
}
public string TargetId { get; }
public MessageEnvelope Envelope { get; }
[JsonIgnore]
internal Executor? TargetCache { get; set; }
}
@@ -1,18 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.Workflows.Checkpointing;
namespace Microsoft.Agents.Workflows.Execution;
internal sealed class MessageEnvelope(object message, TypeId? declaredType = null, string? targetId = null)
internal sealed class MessageEnvelope(object message, ExecutorIdentity source, TypeId? declaredType = null, string? targetId = null)
{
public TypeId MessageType => declaredType ?? new(message.GetType());
public object Message => message;
public ExecutorIdentity Source => source;
public string? TargetId => targetId;
internal MessageEnvelope(object message, Type declaredType, string? targetId = null)
: this(message, new TypeId(declaredType), targetId)
[MemberNotNullWhen(false, nameof(SourceId))]
public bool IsExternal => this.Source == ExecutorIdentity.None;
public string? SourceId => this.Source.Id;
internal MessageEnvelope(object message, ExecutorIdentity source, Type declaredType, string? targetId = null)
: this(message, source, new TypeId(declaredType), targetId)
{
if (!declaredType.IsAssignableFrom(message.GetType()))
{
@@ -5,9 +5,9 @@ using Microsoft.Agents.Workflows.Checkpointing;
namespace Microsoft.Agents.Workflows.Execution;
internal sealed class RunnerStateData(HashSet<string> instantiatedExecutors, Dictionary<ExecutorIdentity, List<PortableMessageEnvelope>> queuedMessages, List<ExternalRequest> outstandingRequests)
internal sealed class RunnerStateData(HashSet<string> instantiatedExecutors, Dictionary<string, List<PortableMessageEnvelope>> queuedMessages, List<ExternalRequest> outstandingRequests)
{
public HashSet<string> InstantiatedExecutors { get; } = instantiatedExecutors;
public Dictionary<ExecutorIdentity, List<PortableMessageEnvelope>> QueuedMessages { get; } = queuedMessages;
public Dictionary<string, List<PortableMessageEnvelope>> QueuedMessages { get; } = queuedMessages;
public List<ExternalRequest> OutstandingRequests { get; } = outstandingRequests;
}
@@ -8,15 +8,15 @@ namespace Microsoft.Agents.Workflows.Execution;
internal sealed class StepContext
{
public Dictionary<ExecutorIdentity, List<MessageEnvelope>> QueuedMessages { get; } = [];
public Dictionary<string, List<MessageEnvelope>> QueuedMessages { get; } = [];
public bool HasMessages => this.QueuedMessages.Values.Any(messageList => messageList.Count > 0);
public List<MessageEnvelope> MessagesFor(string? executorId)
public List<MessageEnvelope> MessagesFor(string target)
{
if (!this.QueuedMessages.TryGetValue(executorId, out var messages))
if (!this.QueuedMessages.TryGetValue(target, out var messages))
{
this.QueuedMessages[executorId] = messages = [];
this.QueuedMessages[target] = messages = [];
}
return messages;
@@ -24,7 +24,7 @@ internal sealed class StepContext
// TODO: Create a MessageEnvelope class that extends from the ExportedState object (with appropriate rename) to avoid
// unnecessary wrapping and unwrapping of messages during checkpointing.
internal Dictionary<ExecutorIdentity, List<PortableMessageEnvelope>> ExportMessages()
internal Dictionary<string, List<PortableMessageEnvelope>> ExportMessages()
{
return this.QueuedMessages.Keys.ToDictionary(
keySelector: identity => identity,
@@ -33,9 +33,9 @@ internal sealed class StepContext
);
}
internal void ImportMessages(Dictionary<ExecutorIdentity, List<PortableMessageEnvelope>> messages)
internal void ImportMessages(Dictionary<string, List<PortableMessageEnvelope>> messages)
{
foreach (ExecutorIdentity identity in messages.Keys)
foreach (string identity in messages.Keys)
{
this.QueuedMessages[identity] = messages[identity].ConvertAll(UnwrapExportedState);
}
@@ -13,6 +13,35 @@ internal sealed class ExecutorRegistration(string id, Type executorType, Executo
public string Id { get; } = Throw.IfNullOrEmpty(id);
public Type ExecutorType { get; } = Throw.IfNull(executorType);
public ExecutorFactoryF ProviderAsync { get; } = Throw.IfNull(provider);
public bool IsNotExecutorInstance { get; } = rawData is not Executor;
public bool IsUnresettableSharedInstance { get; } = rawData is Executor && rawData is not IResettableExecutor;
internal async ValueTask<bool> TryResetAsync()
{
if (this.IsUnresettableSharedInstance)
{
return false;
}
// If this is not an executor instance, this is a factory, and the expectation is that the factory will
// create separate instances of executors.
if (this.IsNotExecutorInstance)
{
return true;
}
// Technically we definitely know this is true, since if rawData is an Executor, if it was not resettable
// then we would have returned in the first condition, and if rawData is not an Executor, we would have
// returned in the second condition. That only leaves the possibility of rawData is Executor and also
// IResettableExecutor.
if (this.RawExecutorishData is IResettableExecutor resettableExecutor)
{
await resettableExecutor.ResetAsync().ConfigureAwait(false);
return true;
}
return false;
}
internal object? RawExecutorishData { get; } = rawData;
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
namespace Microsoft.Agents.Workflows;
/// <summary>
/// Provides a mechanism to return an executor to a 'reset' state, allowing a workflow containing
/// shared instances of it to be resued after a run is disposed.
/// </summary>
public interface IResettableExecutor
{
/// <summary>
/// Reset the executor
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the completion of the reset operation.</returns>
ValueTask ResetAsync()
#if NET
{
return default;
}
#else
;
#endif
}
@@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
@@ -23,11 +22,12 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
{
public InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, params Type[] knownValidInputTypes)
{
this.Workflow = Throw.IfNull(workflow);
this.RunContext = new InProcessRunnerContext(workflow);
this.CheckpointManager = checkpointManager;
this.RunId = runId ?? Guid.NewGuid().ToString("N");
this.Workflow = Throw.IfNull(workflow);
this.RunContext = new InProcessRunnerContext(workflow, this.RunId, this.StepTracer);
this.CheckpointManager = checkpointManager;
this._knownValidInputTypes = [.. knownValidInputTypes];
// Initialize the runners for each of the edges, along with the state for edges that
@@ -56,40 +56,37 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
return false;
}
public async ValueTask<bool> EnqueueMessageAsync<T>(T message)
private async ValueTask<bool> EnqueueMessageInternalAsync(object message, Type messageType)
{
this.RunContext.CheckEnded();
Throw.IfNull(message);
if (message is ExternalResponse response)
{
await this.RunContext.AddExternalResponseAsync(response).ConfigureAwait(false);
}
// Check that the type of the incoming message is compatible with the starting executor's
// input type.
if (!await this.IsValidInputTypeAsync(typeof(T)).ConfigureAwait(false))
if (!await this.IsValidInputTypeAsync(messageType).ConfigureAwait(false))
{
return false;
}
await this.RunContext.AddExternalMessageAsync(message).ConfigureAwait(false);
await this.RunContext.AddExternalMessageAsync(message, messageType).ConfigureAwait(false);
return true;
}
public async ValueTask<bool> EnqueueMessageAsync(object message)
{
Throw.IfNull(message);
public ValueTask<bool> EnqueueMessageAsync<T>(T message)
=> this.EnqueueMessageInternalAsync(Throw.IfNull(message), typeof(T));
// Check that the type of the incoming message is compatible with the starting executor's
// input type.
if (!await this.IsValidInputTypeAsync(message.GetType()).ConfigureAwait(false))
{
return false;
}
await this.RunContext.AddExternalMessageUntypedAsync(message).ConfigureAwait(false);
return true;
}
public ValueTask<bool> EnqueueMessageAsync(object message)
=> this.EnqueueMessageInternalAsync(Throw.IfNull(message), message.GetType());
ValueTask ISuperStepRunner.EnqueueResponseAsync(ExternalResponse response)
{
// TODO: Check that there exists a corresponding input port?
return this.RunContext.AddExternalMessageAsync(response);
return this.RunContext.AddExternalResponseAsync(response);
}
private InProcStepTracer StepTracer { get; } = new();
@@ -111,28 +108,9 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
this.WorkflowEvent?.Invoke(this, workflowEvent);
}
private ValueTask<IEnumerable<object?>> RouteExternalMessageAsync(MessageEnvelope envelope)
{
Debug.Assert(envelope.TargetId is null, "External Messages cannot be targeted to a specific executor.");
object message = envelope.Message;
return message is ExternalResponse response
? this.CompleteExternalResponseAsync(response)
: this.EdgeMap.InvokeInputAsync(envelope);
}
private ValueTask<IEnumerable<object?>> CompleteExternalResponseAsync(ExternalResponse response)
{
if (!this.RunContext.CompleteRequest(response.RequestId))
{
throw new InvalidOperationException($"No pending request with ID {response.RequestId} found in the workflow context.");
}
return this.EdgeMap.InvokeResponseAsync(response);
}
public async ValueTask<StreamingRun> ResumeStreamAsync(CheckpointInfo checkpoint, CancellationToken cancellation = default)
{
this.RunContext.CheckEnded();
Throw.IfNull(checkpoint);
if (this.CheckpointManager is null)
{
@@ -146,6 +124,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
public async ValueTask<StreamingRun> StreamAsync(object input, CancellationToken cancellation = default)
{
this.RunContext.CheckEnded();
await this.EnqueueMessageAsync(input).ConfigureAwait(false);
return new StreamingRun(this);
@@ -153,6 +132,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
public async ValueTask<StreamingRun> StreamAsync<TInput>(TInput input, CancellationToken cancellation = default)
{
this.RunContext.CheckEnded();
await this.EnqueueMessageAsync(input).ConfigureAwait(false);
return new StreamingRun(this);
@@ -160,6 +140,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
internal async ValueTask<Run> ResumeAsync(CheckpointInfo checkpoint, CancellationToken cancellation = default)
{
this.RunContext.CheckEnded();
StreamingRun streamingRun = await this.ResumeStreamAsync(checkpoint, cancellation).ConfigureAwait(false);
cancellation.ThrowIfCancellationRequested();
@@ -168,6 +149,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
public async ValueTask<Run> RunAsync(object input, CancellationToken cancellation = default)
{
this.RunContext.CheckEnded();
StreamingRun streamingRun = await this.StreamAsync(input, cancellation).ConfigureAwait(false);
cancellation.ThrowIfCancellationRequested();
@@ -176,6 +158,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
public async ValueTask<Run> RunAsync<TInput>(TInput input, CancellationToken cancellation = default)
{
this.RunContext.CheckEnded();
StreamingRun streamingRun = await this.StreamAsync(input, cancellation).ConfigureAwait(false);
cancellation.ThrowIfCancellationRequested();
@@ -189,12 +172,13 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
async ValueTask<bool> ISuperStepRunner.RunSuperStepAsync(CancellationToken cancellation)
{
this.RunContext.CheckEnded();
if (cancellation.IsCancellationRequested)
{
return false;
}
StepContext currentStep = this.RunContext.Advance();
StepContext currentStep = await this.RunContext.AdvanceAsync().ConfigureAwait(false);
if (currentStep.HasMessages)
{
@@ -218,32 +202,32 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
}
}
private async ValueTask DeliverMessagesAsync(string receiverId, List<MessageEnvelope> envelopes)
{
Executor executor = await this.RunContext.EnsureExecutorAsync(receiverId, this.StepTracer).ConfigureAwait(false);
this.StepTracer.TraceActivated(receiverId);
foreach (MessageEnvelope envelope in envelopes)
{
await executor.ExecuteAsync(envelope.Message, envelope.MessageType, this.RunContext.Bind(receiverId))
.ConfigureAwait(false);
}
}
private async ValueTask RunSuperstepAsync(StepContext currentStep)
{
this.RaiseWorkflowEvent(this.StepTracer.Advance(currentStep));
// Deliver the messages and queue the next step
List<Task<IEnumerable<object?>>> edgeTasks = [];
foreach (ExecutorIdentity sender in currentStep.QueuedMessages.Keys)
{
IEnumerable<MessageEnvelope> senderMessages = currentStep.QueuedMessages[sender];
if (sender.Id is null)
{
edgeTasks.AddRange(senderMessages.Select(envelope => this.RouteExternalMessageAsync(envelope).AsTask()));
}
else if (this.Workflow.Edges.TryGetValue(sender.Id!, out HashSet<Edge>? outgoingEdges))
{
foreach (Edge outgoingEdge in outgoingEdges)
{
edgeTasks.AddRange(senderMessages.Select(envelope => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, envelope).AsTask()));
}
}
}
List<Task> receiverTasks =
currentStep.QueuedMessages.Keys
.Select(receiverId => this.DeliverMessagesAsync(receiverId, currentStep.MessagesFor(receiverId)).AsTask())
.ToList();
// TODO: Should we let the user specify that they want strictly turn-based execution of the edges, vs. concurrent?
// (Simply substitute a strategy that replaces Task.WhenAll with a loop with an await in the middle. Difficulty is
// that we would need to avoid firing the tasks when we call InvokeEdgeAsync, or RouteExternalMessageAsync.
IEnumerable<object?> results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r);
await Task.WhenAll(receiverTasks).ConfigureAwait(false);
// After the message handler invocations, we may have some events to deliver
this.EmitPendingEvents();
@@ -257,6 +241,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
private readonly List<CheckpointInfo> _checkpoints = [];
internal async ValueTask CheckpointAsync(CancellationToken cancellation = default)
{
this.RunContext.CheckEnded();
if (this.CheckpointManager is null)
{
// Always publish the state updates, even in the absence of a CheckpointManager.
@@ -286,6 +271,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
public async ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellation = default)
{
this.RunContext.CheckEnded();
Throw.IfNull(checkpointInfo);
if (this.CheckpointManager is null)
{
@@ -316,4 +302,6 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
private bool CheckWorkflowMatch(Checkpoint checkpoint) =>
checkpoint.Workflow.IsMatch(this.Workflow);
ValueTask ISuperStepRunner.RequestEndRunAsync() => this.RunContext.EndRunAsync();
}
@@ -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;
@@ -16,81 +17,157 @@ namespace Microsoft.Agents.Workflows.InProc;
internal sealed class InProcessRunnerContext : IRunnerContext
{
private StepContext _nextStep = new();
private readonly Dictionary<string, ExecutorRegistration> _executorRegistrations;
private readonly Dictionary<string, Executor> _executors = [];
private readonly Dictionary<string, ExternalRequest> _externalRequests = [];
private int _runEnded;
private readonly string _runId;
private readonly Workflow _workflow;
private readonly EdgeMap _edgeMap;
private readonly OutputFilter _outputFilter;
public InProcessRunnerContext(Workflow workflow, ILogger? logger = null)
private StepContext _nextStep = new();
private readonly ConcurrentDictionary<string, Task<Executor>> _executors = new();
private readonly ConcurrentQueue<Func<ValueTask>> _queuedExternalDeliveries = new();
private readonly Dictionary<string, ExternalRequest> _externalRequests = new();
public InProcessRunnerContext(Workflow workflow, string runId, IStepTracer? stepTracer, ILogger? logger = null)
{
this._executorRegistrations = Throw.IfNull(workflow).Registrations;
workflow.TakeOwnership(this);
this._workflow = workflow;
this._runId = runId;
this._edgeMap = new(this, this._workflow, stepTracer);
this._outputFilter = new(workflow);
}
public async ValueTask<Executor> EnsureExecutorAsync(string executorId, IStepTracer? tracer)
{
if (!this._executors.TryGetValue(executorId, out var executor))
this.CheckEnded();
Task<Executor> executorTask = this._executors.GetOrAdd(executorId, CreateExecutorAsync);
async Task<Executor> CreateExecutorAsync(string id)
{
if (!this._executorRegistrations.TryGetValue(executorId, out var registration))
if (!this._workflow.Registrations.TryGetValue(executorId, out var registration))
{
throw new InvalidOperationException($"Executor with ID '{executorId}' is not registered.");
}
this._executors[executorId] = executor = await registration.ProviderAsync().ConfigureAwait(false);
Executor executor = await registration.ProviderAsync().ConfigureAwait(false);
tracer?.TraceActivated(executorId);
if (executor is RequestInfoExecutor requestInputExecutor)
{
requestInputExecutor.AttachRequestSink(this);
}
return executor;
}
return executor;
return await executorTask.ConfigureAwait(false);
}
public ValueTask AddExternalMessageUntypedAsync(object message)
public ValueTask AddExternalMessageAsync(object message, Type declaredType)
{
this.CheckEnded();
Throw.IfNull(message);
this._nextStep.MessagesFor(ExecutorIdentity.None).Add(new MessageEnvelope(message));
this._queuedExternalDeliveries.Enqueue(PrepareExternalDeliveryAsync);
return default;
async ValueTask PrepareExternalDeliveryAsync()
{
DeliveryMapping? maybeMapping =
await this._edgeMap.PrepareDeliveryForInputAsync(new(message, ExecutorIdentity.None, declaredType))
.ConfigureAwait(false);
maybeMapping?.MapInto(this._nextStep);
}
}
public ValueTask AddExternalMessageAsync<T>(T message)
public ValueTask AddExternalResponseAsync(ExternalResponse response)
{
Throw.IfNull(message);
this.CheckEnded();
Throw.IfNull(response);
this._nextStep.MessagesFor(ExecutorIdentity.None).Add(new MessageEnvelope(message, declaredType: typeof(T)));
this._queuedExternalDeliveries.Enqueue(PrepareExternalDeliveryAsync);
return default;
async ValueTask PrepareExternalDeliveryAsync()
{
if (!this.CompleteRequest(response.RequestId))
{
throw new InvalidOperationException($"No pending request with ID {response.RequestId} found in the workflow context.");
}
DeliveryMapping? maybeMapping =
await this._edgeMap.PrepareDeliveryForResponseAsync(response)
.ConfigureAwait(false);
maybeMapping?.MapInto(this._nextStep);
}
}
public bool NextStepHasActions => this._nextStep.HasMessages;
public bool NextStepHasActions => this._nextStep.HasMessages || !this._queuedExternalDeliveries.IsEmpty;
public bool HasUnservicedRequests => this._externalRequests.Count > 0;
public StepContext Advance() => Interlocked.Exchange(ref this._nextStep, new StepContext());
public async ValueTask<StepContext> AdvanceAsync()
{
this.CheckEnded();
while (this._queuedExternalDeliveries.TryDequeue(out var deliveryPrep))
{
// It's important we do not try to run these in parallel, because they make be modifying
// inner edge state, etc.
await deliveryPrep().ConfigureAwait(false);
}
return Interlocked.Exchange(ref this._nextStep, new StepContext());
}
public ValueTask AddEventAsync(WorkflowEvent workflowEvent)
{
this.CheckEnded();
this.QueuedEvents.Add(workflowEvent);
return default;
}
public ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null)
public async ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null)
{
this._nextStep.MessagesFor(sourceId).Add(new MessageEnvelope(message, targetId: targetId));
return default;
this.CheckEnded();
MessageEnvelope envelope = new(message, sourceId, targetId: targetId);
if (this._workflow.Edges.TryGetValue(sourceId, out HashSet<Edge>? edges))
{
foreach (Edge edge in edges)
{
DeliveryMapping? maybeMapping =
await this._edgeMap.PrepareDeliveryForEdgeAsync(edge, envelope)
.ConfigureAwait(false);
maybeMapping?.MapInto(this._nextStep);
}
}
}
public IWorkflowContext Bind(string executorId) => new BoundContext(this, executorId, this._outputFilter);
public IWorkflowContext Bind(string executorId)
{
this.CheckEnded();
return new BoundContext(this, executorId, this._outputFilter);
}
public ValueTask PostAsync(ExternalRequest request)
{
this.CheckEnded();
this._externalRequests.Add(request.RequestId, request);
return this.AddEventAsync(new RequestInfoEvent(request));
}
public bool CompleteRequest(string requestId) => this._externalRequests.Remove(requestId);
public bool CompleteRequest(string requestId)
{
this.CheckEnded();
return this._externalRequests.Remove(requestId);
}
public readonly List<WorkflowEvent> QueuedEvents = [];
@@ -103,6 +180,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
public async ValueTask YieldOutputAsync(object output)
{
RunnerContext.CheckEnded();
Throw.IfNull(output);
Executor sourceExecutor = await RunnerContext.EnsureExecutorAsync(ExecutorId, tracer: null).ConfigureAwait(false);
@@ -132,18 +210,42 @@ internal sealed class InProcessRunnerContext : IRunnerContext
=> RunnerContext.StateManager.ClearStateAsync(ExecutorId, scopeName);
}
internal Task PrepareForCheckpointAsync(CancellationToken cancellation = default) => Task.WhenAll(this._executors.Values.Select(executor => executor.OnCheckpointingAsync(this.Bind(executor.Id), cancellation).AsTask()));
internal Task PrepareForCheckpointAsync(CancellationToken cancellation = default)
{
this.CheckEnded();
internal Task NotifyCheckpointLoadedAsync(CancellationToken cancellationToken = default) => Task.WhenAll(this._executors.Values.Select(executor => executor.OnCheckpointRestoredAsync(this.Bind(executor.Id), cancellationToken).AsTask()));
return Task.WhenAll(this._executors.Values.Select(InvokeCheckpointingAsync));
async Task InvokeCheckpointingAsync(Task<Executor> executorTask)
{
Executor executor = await executorTask.ConfigureAwait(false);
await executor.OnCheckpointingAsync(this.Bind(executor.Id), cancellation).ConfigureAwait(false);
}
}
internal Task NotifyCheckpointLoadedAsync(CancellationToken cancellation = default)
{
this.CheckEnded();
return Task.WhenAll(this._executors.Values.Select(InvokeCheckpointRestoredAsync));
async Task InvokeCheckpointRestoredAsync(Task<Executor> executorTask)
{
Executor executor = await executorTask.ConfigureAwait(false);
await executor.OnCheckpointRestoredAsync(this.Bind(executor.Id), cancellation).ConfigureAwait(false);
}
}
internal ValueTask<RunnerStateData> ExportStateAsync()
{
this.CheckEnded();
if (this.QueuedEvents.Count > 0)
{
throw new InvalidOperationException("Cannot export state when there are queued events. Please process or clear the events before exporting state.");
}
Dictionary<ExecutorIdentity, List<PortableMessageEnvelope>> queuedMessages = this._nextStep.ExportMessages();
Dictionary<string, List<PortableMessageEnvelope>> queuedMessages = this._nextStep.ExportMessages();
RunnerStateData result = new(instantiatedExecutors: [.. this._executors.Keys],
queuedMessages,
outstandingRequests: [.. this._externalRequests.Values]);
@@ -153,6 +255,8 @@ internal sealed class InProcessRunnerContext : IRunnerContext
internal async ValueTask RepublishUnservicedRequestsAsync(CancellationToken cancellation = default)
{
this.CheckEnded();
if (this.HasUnservicedRequests)
{
foreach (string requestId in this._externalRequests.Keys)
@@ -165,6 +269,8 @@ internal sealed class InProcessRunnerContext : IRunnerContext
internal async ValueTask ImportStateAsync(Checkpoint checkpoint)
{
this.CheckEnded();
if (this.QueuedEvents.Count > 0)
{
throw new InvalidOperationException("Cannot import state when there are queued events. Please process or clear the events before importing state.");
@@ -192,4 +298,22 @@ internal sealed class InProcessRunnerContext : IRunnerContext
await Task.WhenAll(executorTasks).ConfigureAwait(false);
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Maintainability", "CA1513:Use ObjectDisposedException throw helper",
Justification = "Does not exist in NetFx 4.7.2")]
internal void CheckEnded()
{
if (Volatile.Read(ref this._runEnded) == 1)
{
throw new InvalidOperationException($"Workflow run '{this._runId}' has been ended. Please start a new Run or StreamingRun.");
}
}
public async ValueTask EndRunAsync()
{
if (Interlocked.Exchange(ref this._runEnded, 1) == 0)
{
await this._workflow.ReleaseOwnershipAsync(this).ConfigureAwait(false);
}
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Agents.Workflows.Checkpointing;
@@ -92,82 +93,78 @@ public sealed class PortableValue
/// <remarks>If the underlying value implements delayed deserialization, this method will attempt to
/// deserialize it to the specified type. If the value is already of the requested type, it is returned directly.
/// Otherwise, the default value for TValue is returned.
///
/// For nullable value types, make sure to make <typeparamref name="TValue"/> be nullable, e.g. <c>int?</c>,
/// otherwise the default non-null value of the type is returned when the value is missing. Use <see cref="AsValue{TValue}"/>
/// to get the correct behavior when unable to pass in the explicit-nullable type.
/// </remarks>
/// <typeparam name="TValue">The type to which the value should be cast or deserialized.</typeparam>
/// <returns>The value cast or deserialized to type TValue if possible; otherwise, the default value for type TValue.</returns>
public TValue? As<TValue>()
{
if (this.Value is IDelayedDeserialization delayedDeserialization)
{
this._deserializedValueCache ??= delayedDeserialization.Deserialize<TValue>();
}
if (this.Value is TValue typedValue)
{
return typedValue;
}
return default;
}
/// <summary>
/// Attempts to retrieve the underlying value as the specified nullable value type, deserializing if
/// necessary.
/// </summary>
/// <remarks>If the underlying value implements delayed deserialization, this method will attempt to
/// deserialize it to the specified type. If the value is already of the requested type, it is returned directly.
/// Otherwise, null is returned.</remarks>
/// <typeparam name="TValue">The value type to which the value should be cast or deserialized.</typeparam>
/// <returns>The value cast or deserialized to type TValue if possible; otherwise, null.</returns>
public TValue? AsValue<TValue>() where TValue : struct
{
if (this.Value is IDelayedDeserialization delayedDeserialization)
{
this._deserializedValueCache ??= delayedDeserialization.Deserialize<TValue>();
}
if (this.Value is TValue typedValue)
{
return typedValue;
}
return default;
}
public TValue? As<TValue>() => this.Is(out TValue? value) ? value : default;
/// <summary>
/// Determines whether the current value can be represented as the specified type.
/// </summary>
/// <typeparam name="TValue">The type to test for compatibility with the current value.</typeparam>
/// <returns>true if the current value can be represented as type TValue; otherwise, false.</returns>
public bool Is<TValue>() => this.IsType(typeof(TValue));
public bool Is<TValue>() => this.Is<TValue>(out _);
/// <summary>
/// Determines whether the current value can be represented as the specified type.
/// </summary>
/// <typeparam name="TValue">The type to test for compatibility with the current value.</typeparam>
/// <param name="value">When this method returns, contains the value cast or deserialized to type TValue
/// if the conversion succeeded, or null if the conversion failed.</param>
/// <returns>true if the current value can be represented as type TValue; otherwise, false.</returns>
public bool Is<TValue>([NotNullWhen(true)] out TValue? value)
{
if (this.Value is IDelayedDeserialization delayedDeserialization)
{
this._deserializedValueCache ??= delayedDeserialization.Deserialize<TValue>();
}
if (this.Value is TValue typedValue)
{
value = typedValue;
return true;
}
value = default;
return false;
}
/// <summary>
/// Attempts to retrieve the underlying value as the specified type, deserializing if necessary.
/// </summary>
/// <param name="targetType">The type to which the value should be cast or deserialized.</param>
/// <returns>The value cast or deserialized to type targetType if possible; otherwise, null.</returns>
public object? AsType(Type targetType)
{
Throw.IfNull(targetType);
if (this.Value is IDelayedDeserialization delayedDeserialization)
{
this._deserializedValueCache ??= delayedDeserialization.Deserialize(targetType);
}
return this.Value is not null && targetType.IsAssignableFrom(this.Value.GetType())
? this.Value
: this._deserializedValueCache = null;
}
public object? AsType(Type targetType) => this.IsType(targetType, out object? value) ? value : null;
/// <summary>
/// Determines whether the current instance can be assigned to the specified target type.
/// </summary>
/// <param name="targetType">The type to compare with the current instance. Cannot be null.</param>
/// <returns>true if the current instance can be assigned to targetType; otherwise, false.</returns>
public bool IsType(Type targetType) => this.AsType(targetType) is not null;
public bool IsType(Type targetType) => this.IsType(targetType, out _);
/// <summary>
/// Determines whether the current instance can be assigned to the specified target type.
/// </summary>
/// <param name="targetType">The type to compare with the current instance. Cannot be null.</param>
/// <param name="value">When this method returns, contains the value cast or deserialized to type TValue
/// if the conversion succeeded, or null if the conversion failed.</param>
/// <returns>true if the current instance can be assigned to targetType; otherwise, false.</returns>
public bool IsType(Type targetType, out object? value)
{
Throw.IfNull(targetType);
if (this.Value is IDelayedDeserialization delayedDeserialization)
{
this._deserializedValueCache ??= delayedDeserialization.Deserialize(targetType);
}
if (this.Value is not null && targetType.IsAssignableFrom(this.Value.GetType()))
{
value = this.Value;
return true;
}
value = null;
return false;
}
}
+4 -1
View File
@@ -38,7 +38,7 @@ public enum RunStatus
/// Represents a workflow run that tracks execution status and emitted workflow events, supporting resumption
/// with responses to <see cref="RequestInfoEvent"/>.
/// </summary>
public class Run
public sealed class Run
{
internal static async ValueTask<Run> CaptureStreamAsync(StreamingRun run, CancellationToken cancellation = default)
{
@@ -147,4 +147,7 @@ public class Run
return await this.RunToNextHaltAsync(cancellation).ConfigureAwait(false);
}
/// <inheritdoc cref="StreamingRun.EndRunAsync"/>
public ValueTask EndRunAsync() => this._streamingRun.EndRunAsync();
}
@@ -15,7 +15,7 @@ namespace Microsoft.Agents.Workflows;
/// A <see cref="Workflow"/> run instance supporting a streaming form of receiving workflow events, and providing
/// a mechanism to send responses back to the workflow.
/// </summary>
public class StreamingRun
public sealed class StreamingRun
{
private TaskCompletionSource<object>? _waitForResponseSource;
private readonly ISuperStepRunner _stepRunner;
@@ -157,6 +157,14 @@ public class StreamingRun
eventSink.Add(e);
}
}
/// <summary>
/// Signals the end of the current run and initiates any necessary cleanup operations asynchronously.
/// Enables the underlying Workflow instance to be reused in subsequent runs.
/// </summary>
/// <returns>A ValueTask that represents the asynchronous operation. The task is complete when the run has
/// ended and cleanup is finished.</returns>
public ValueTask EndRunAsync() => this._stepRunner.RequestEndRunAsync();
}
/// <summary>
@@ -2,7 +2,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.Workflows.Checkpointing;
using Microsoft.Shared.Diagnostics;
@@ -99,6 +101,71 @@ public class Workflow
OutputExecutors = this.OutputExecutors
};
}
private bool _needsReset;
private bool IsResettable => this.Registrations.Values.All(registration => !registration.IsUnresettableSharedInstance);
private async ValueTask<bool> TryResetExecutorRegistrationsAsync()
{
if (this.IsResettable)
{
foreach (ExecutorRegistration registration in this.Registrations.Values)
{
if (!await registration.TryResetAsync().ConfigureAwait(false))
{
return false;
}
}
this._needsReset = false;
return true;
}
return false;
}
private object? _ownerToken;
internal void TakeOwnership(object ownerToken)
{
object? maybeToken = Interlocked.CompareExchange(ref this._ownerToken, ownerToken, null);
if (maybeToken == null && this._needsReset)
{
// There is no owner, but the workflow failed to reset on ownership release (because there are
// shared executors).
throw new InvalidOperationException(
"Cannot reuse Workflow with shared Executor instances that do not implement IResettableExecutor."
);
}
if (maybeToken != null && !ReferenceEquals(maybeToken, ownerToken))
{
// Someone else owns the workflow
Debug.Assert(maybeToken != null);
throw new InvalidOperationException("Cannot use a Workflow in multiple simultaneous (Streaming)Runs.");
}
this._needsReset = true;
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Maintainability", "CA1513:Use ObjectDisposedException throw helper",
Justification = "Does not exist in NetFx 4.7.2")]
internal async ValueTask ReleaseOwnershipAsync(object ownerToken)
{
if (this._ownerToken == null)
{
throw new InvalidOperationException("Attempting to release ownership of a Workflow that is not owned.");
}
if (!ReferenceEquals(this._ownerToken, this._ownerToken))
{
throw new InvalidOperationException("Attempt to release ownership of a Workflow by non-owner.");
}
await this.TryResetExecutorRegistrationsAsync().ConfigureAwait(false);
Interlocked.CompareExchange(ref this._ownerToken, null, ownerToken);
this._ownerToken = null;
}
}
/// <summary>
@@ -387,23 +387,30 @@ public class AgentWorkflowBuilderTests
StringBuilder sb = new();
StreamingRun run = await InProcessExecution.StreamAsync(workflow, input);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
WorkflowOutputEvent? output = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
try
{
if (evt is AgentRunUpdateEvent executorComplete)
{
sb.Append(executorComplete.Data);
}
else if (evt is WorkflowOutputEvent e)
{
output = e;
break;
}
}
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
return (sb.ToString(), output?.As<List<ChatMessage>>());
WorkflowOutputEvent? output = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
if (evt is AgentRunUpdateEvent executorComplete)
{
sb.Append(executorComplete.Data);
}
else if (evt is WorkflowOutputEvent e)
{
output = e;
break;
}
}
return (sb.ToString(), output?.As<List<ChatMessage>>());
}
finally
{
await run.EndRunAsync();
}
}
private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox<TaskCompletionSource<bool>> barrier, StrongBox<int> remaining) : DoubleEchoAgent(name)
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.Workflows.Execution;
namespace Microsoft.Agents.Workflows.UnitTests;
@@ -27,10 +29,20 @@ public class EdgeMapSmokeTests
EdgeMap edgeMap = new(runContext, workflowEdges, [], "executor1", null);
await edgeMap.InvokeEdgeAsync(fanInEdge, "executor1", new("part1"));
MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages);
DeliveryMapping? mapping = await edgeMap.PrepareDeliveryForEdgeAsync(fanInEdge, new("part1", "executor1"));
mapping.Should().BeNull();
await edgeMap.InvokeEdgeAsync(fanInEdge, "executor2", new("part2"));
MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages, ("executor3", ["part1", "part2"]));
mapping = await edgeMap.PrepareDeliveryForEdgeAsync(fanInEdge, new("part2", "executor2"));
mapping.Should().NotBeNull();
List<MessageDelivery> deliveries = mapping.Deliveries.ToList();
deliveries.Should().HaveCount(2).And.AllSatisfy(delivery => delivery.TargetId.Should().Be("executor3"));
HashSet<string> expectedMessages = ["part1", "part2"];
foreach (MessageDelivery delivery in deliveries)
{
string message = delivery.Envelope.As<string>()!;
expectedMessages.Remove(message);
}
}
}
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.Workflows.Execution;
namespace Microsoft.Agents.Workflows.UnitTests;
@@ -34,20 +35,21 @@ public class EdgeRunnerTests
DirectEdgeData edgeData = new("executor1", "executor2", new EdgeId(0), condition);
DirectEdgeRunner runner = new(runContext, edgeData);
MessageEnvelope envelope = new(MessageVariant1, targetId: targetId);
MessageEnvelope envelope = new(MessageVariant1, "executor1", targetId: targetId);
await runner.ChaseAsync(envelope, tracer: null);
DeliveryMapping? mapping = await runner.ChaseEdgeAsync(envelope, stepTracer: null);
bool expectMessage = (!conditionMatch.HasValue || conditionMatch.Value)
&& (!targetMatch.HasValue || targetMatch.Value);
if (expectMessage)
{
MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages, ("executor2", [MessageVariant1]));
mapping.Should().NotBeNull();
mapping.CheckDeliveries(["executor2"], [MessageVariant1]);
}
else
{
MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages);
mapping.Should().BeNull();
}
}
@@ -93,26 +95,34 @@ public class EdgeRunnerTests
FanOutEdgeData edgeData = new("executor1", ["executor2", "executor3"], new EdgeId(0), assigner);
FanOutEdgeRunner runner = new(runContext, edgeData);
MessageEnvelope envelope = new("test", targetId: targetId);
MessageEnvelope envelope = new("test", "executor1", targetId: targetId);
await runner.ChaseAsync(envelope, tracer: null);
DeliveryMapping? mapping = await runner.ChaseEdgeAsync(envelope, stepTracer: null);
bool expectForwardFrom2 = (!assignerSelectsEmpty.HasValue || !assignerSelectsEmpty.Value)
&& (!targetMatch.HasValue || targetMatch.Value);
bool expectForwardFrom3 = !assignerSelectsEmpty.HasValue && !targetMatch.HasValue; // if there is a target, it is never executor3
List<(string expectedSender, List<string> expectedMessages)> expectedForwards = [];
HashSet<string> expectedReceivers = new();
if (expectForwardFrom2)
{
expectedForwards.Add(("executor2", ["test"]));
expectedReceivers.Add("executor2");
}
if (expectForwardFrom3)
{
expectedForwards.Add(("executor3", ["test"]));
expectedReceivers.Add("executor3");
}
MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages, expectedForwards.ToArray());
if (!expectForwardFrom2 && !expectForwardFrom3)
{
mapping.Should().BeNull();
}
else
{
mapping.Should().NotBeNull();
mapping.CheckDeliveries(expectedReceivers, ["test"]);
}
}
[Fact]
@@ -153,7 +163,6 @@ public class EdgeRunnerTests
// Step 3: Send message from executor1, should not forward yet.
// Step 4: Send message from executor2, should forward now.
FanInEdgeState state = runner.CreateState();
await RunIterationAsync();
// Repeat the same sequence, to ensure state is properly reset inside of FanInEdgeState.
@@ -162,20 +171,26 @@ public class EdgeRunnerTests
async ValueTask RunIterationAsync()
{
await runner.ChaseAsync("executor1", new("part1"), state, tracer: null);
//await runner.ChaseAsync("executor1", new("part1"), state, tracer: null);
//MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages);
DeliveryMapping? mapping = await runner.ChaseEdgeAsync(new("part1", "executor1"), stepTracer: null);
mapping.Should().BeNull();
MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages);
//await runner.ChaseAsync("executor2", new("part-for-1", targetId: "executor1"), state, tracer: null);
//MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages);
mapping = await runner.ChaseEdgeAsync(new("part-for-1", "executor2", targetId: "executor1"), stepTracer: null);
mapping.Should().BeNull();
await runner.ChaseAsync("executor2", new("part-for-1", targetId: "executor1"), state, tracer: null);
MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages);
//await runner.ChaseAsync("executor1", new("part2", targetId: "executor3"), state, tracer: null);
//MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages);
mapping = await runner.ChaseEdgeAsync(new("part2", "executor1", targetId: "executor3"), stepTracer: null);
mapping.Should().BeNull();
await runner.ChaseAsync("executor1", new("part2", targetId: "executor3"), state, tracer: null);
MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages);
await runner.ChaseAsync("executor2", new("final part"), state, tracer: null);
MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages, ("executor3", ["part1", "part2", "final part"]));
//await runner.ChaseAsync("executor2", new("final part"), state, tracer: null);
//MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages, ("executor3", ["part1", "part2", "final part"]));
mapping = await runner.ChaseEdgeAsync(new("final part", "executor2"), stepTracer: null);
mapping.Should().NotBeNull();
mapping.CheckDeliveries(["executor3"], ["part1", "part2", "final part"]);
}
}
}
@@ -103,6 +103,9 @@ public class InProcessStateTests
Run run = await InProcessExecution.RunAsync<TurnToken>(workflow, new());
run.Status.Should().Be(RunStatus.Idle);
writer.Completed.Should().BeTrue();
validator.Completed.Should().BeTrue();
}
[Fact]
@@ -129,8 +132,11 @@ public class InProcessStateTests
Checkpointed<Run> checkpointed = await InProcessExecution.RunAsync<TurnToken>(workflow, new(), CheckpointManager.Default);
checkpointed.Checkpoints.Should().HaveCount(6);
checkpointed.Checkpoints.Should().HaveCount(5);
checkpointed.Run.Status.Should().Be(RunStatus.Idle);
writer.Completed.Should().BeTrue();
validator.Completed.Should().BeTrue();
}
[Fact]
@@ -378,7 +378,7 @@ public class JsonSerializationTests
{
const string Message = "TestMessage";
MessageEnvelope envelope = new(Message, new TypeId(typeof(object)), targetId: "Target1");
MessageEnvelope envelope = new(Message, "Source1", new TypeId(typeof(object)), targetId: "Target1");
PortableMessageEnvelope value = new(envelope);
PortableMessageEnvelope result = RunJsonRoundtrip(value);
@@ -402,7 +402,7 @@ public class JsonSerializationTests
{
ChatMessage message = new(ChatRole.User, "Hello, world!");
MessageEnvelope envelope = new(message, new TypeId(typeof(object)), targetId: "Target1");
MessageEnvelope envelope = new(message, "Source1", new TypeId(typeof(object)), targetId: "Target1");
PortableMessageEnvelope value = new(envelope);
PortableMessageEnvelope result = RunJsonRoundtrip(value);
@@ -433,7 +433,7 @@ public class JsonSerializationTests
{
TestJsonSerializable message = new() { Id = 42, Name = "Test" };
MessageEnvelope envelope = new(message, new TypeId(typeof(object)), targetId: "Target1");
MessageEnvelope envelope = new(message, "Source1", new TypeId(typeof(object)), targetId: "Target1");
PortableMessageEnvelope value = new(envelope);
PortableMessageEnvelope result = RunJsonRoundtrip(value, TestCustomSerializedJsonOptions);
@@ -462,15 +462,12 @@ public class JsonSerializationTests
outstandingRequests: [TestExternalRequest]
);
static Dictionary<ExecutorIdentity, List<PortableMessageEnvelope>> CreateQueuedMessages()
static Dictionary<string, List<PortableMessageEnvelope>> CreateQueuedMessages()
{
Dictionary<ExecutorIdentity, List<PortableMessageEnvelope>> result = [];
Dictionary<string, List<PortableMessageEnvelope>> result = [];
MessageEnvelope externalEnvelope = new(TestExternalResponse);
result.Add(ExecutorIdentity.None, [new(externalEnvelope)]);
MessageEnvelope internalEnvelope = new("InternalMessage");
result.Add("TestExecutor1", [new(internalEnvelope)]);
MessageEnvelope internalEnvelope = new("InternalMessage", "TestExecutor1");
result.Add("TestExecutor2", [new(internalEnvelope)]);
return result;
}
@@ -485,7 +482,7 @@ public class JsonSerializationTests
(Action<string>)(actual => actual.Should().Be(prototype))).ToArray());
result.QueuedMessages.Should().HaveCount(prototype.QueuedMessages.Count);
foreach (ExecutorIdentity key in prototype.QueuedMessages.Keys)
foreach (string key in prototype.QueuedMessages.Keys)
{
result.QueuedMessages.Should().ContainKey(key);
@@ -524,7 +521,7 @@ public class JsonSerializationTests
private static PortableValue CreateEdgeState<TMessage>(TMessage message) where TMessage : notnull
{
FanInEdgeState state = TestFanInEdgeState;
_ = state.ProcessMessage("SourceExecutor1", new MessageEnvelope(message, typeof(TMessage)));
_ = state.ProcessMessage("SourceExecutor1", new MessageEnvelope(message, "SourceExecutor1", typeof(TMessage)));
return new(state);
}
@@ -10,6 +10,40 @@ namespace Microsoft.Agents.Workflows.UnitTests;
internal static class MessageDeliveryValidation
{
public static void CheckDeliveries(this DeliveryMapping mapping, HashSet<string> receiverIds, HashSet<object> messages)
{
HashSet<string> unseenReceivers = [.. receiverIds];
HashSet<object> unseenMessages = [.. messages];
foreach (IGrouping<string, MessageDelivery> grouping in mapping.Deliveries.GroupBy(delivery => delivery.TargetId))
{
string receiverId = grouping.Key;
receiverIds.Should().Contain(receiverId);
unseenReceivers.Remove(grouping.Key);
foreach (MessageDelivery delivery in grouping)
{
object messageValue;
if (delivery.Envelope.Message is PortableValue portableValue)
{
portableValue.IsDelayedDeserialization.Should().BeFalse();
messageValue = portableValue.Value;
}
else
{
messageValue = delivery.Envelope.Message;
}
messages.Should().Contain(messageValue);
unseenMessages.Remove(messageValue);
}
}
unseenReceivers.Should().BeEmpty();
unseenMessages.Should().BeEmpty();
}
public static void CheckForwarded(Dictionary<string, List<MessageEnvelope>> queuedMessages, params (string expectedSender, List<string> expectedMessages)[] expectedForwards)
{
queuedMessages.Should().HaveCount(expectedForwards.Length);
@@ -92,7 +92,7 @@ internal sealed class GuessNumberExecutor : ReflectingExecutor<GuessNumberExecut
}
}
internal sealed class JudgeExecutor : ReflectingExecutor<JudgeExecutor>, IMessageHandler<int, NumberSignal>
internal sealed class JudgeExecutor : ReflectingExecutor<JudgeExecutor>, IMessageHandler<int, NumberSignal>, IResettableExecutor
{
private readonly int _targetNumber;
@@ -122,4 +122,10 @@ internal sealed class JudgeExecutor : ReflectingExecutor<JudgeExecutor>, IMessag
{
this.Tries = await context.ReadStateAsync<int?>("TryCount").ConfigureAwait(false) ?? 0;
}
public ValueTask ResetAsync()
{
this.Tries = null;
return default;
}
}
@@ -39,6 +39,8 @@ internal static class Step5EntryPoint
if (rehydrateToRestore)
{
await handle.EndRunAsync().ConfigureAwait(false);
checkpointed = await InProcessExecution.ResumeStreamAsync(workflow, targetCheckpoint, checkpointManager, runId: handle.RunId, cancellation: CancellationToken.None)
.ConfigureAwait(false);
handle = checkpointed.Run;
@@ -59,7 +61,7 @@ internal static class Step5EntryPoint
result = await RunStreamToHaltOrMaxStepAsync().ConfigureAwait(false);
result.Should().NotBeNull();
checkpoints.Should().HaveCount(7);
checkpoints.Should().HaveCount(6);
cancellationSource.Dispose();
@@ -61,11 +61,11 @@ public class TestRunContext : IRunnerContext
this.QueuedMessages[sourceId] = deliveryQueue = [];
}
deliveryQueue.Add(new(message, targetId: targetId));
deliveryQueue.Add(new(message, sourceId, targetId: targetId));
return default;
}
StepContext IRunnerContext.Advance() =>
ValueTask<StepContext> IRunnerContext.AdvanceAsync() =>
throw new NotImplementedException();
public Dictionary<string, Executor> Executors { get; } = [];
@@ -15,6 +15,10 @@ internal abstract class TestingExecutor<TIn, TOut> : Executor, IDisposable
private readonly HashSet<CancellationToken> _linkedTokens = [];
private CancellationTokenSource _internalCts = new();
public int Iterations { get; private set; }
public bool AtEnd => this._nextActionIndex >= this._actions.Length;
public bool Completed => !this._loop && this.AtEnd;
protected TestingExecutor(string id, bool loop = false, params Func<TIn, IWorkflowContext, CancellationToken, ValueTask<TOut>>[] actions) : base(id)
{
this._loop = loop;
@@ -41,10 +45,11 @@ internal abstract class TestingExecutor<TIn, TOut> : Executor, IDisposable
private int _nextActionIndex;
private ValueTask<TOut> RouteToActionsAsync(TIn message, IWorkflowContext context)
{
if (this._nextActionIndex >= this._actions.Length)
if (this.AtEnd)
{
if (this._loop)
{
this.Iterations++;
this._nextActionIndex = 0;
}
else