mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: [Feature Branch] Add basic durable workflow support (#3648)
* Add basic durable workflow support. * PR feedback fixes * Add conditional edge sample. * PR feedback fixes. * Minor cleanup. * Minor cleanup * Minor formatting improvements. * Improve comments/documentation on the execution flow.
This commit is contained in:
+156
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Routing decision flow for a single edge.
|
||||
// Example: the B→D edge from a workflow like below:
|
||||
//
|
||||
// [A] ──► [B] ──► [C] ──► [E] (B→D has condition: x => x.NeedsReview)
|
||||
// │ ▲
|
||||
// └──► [D] ──────┘
|
||||
//
|
||||
// (condition: x => x.NeedsReview, _sourceOutputType: typeof(Order))
|
||||
//
|
||||
// RouteMessage(envelope) envelope.Message = "{\"NeedsReview\":true, ...}"
|
||||
// │
|
||||
// ▼
|
||||
// Has condition? ──── No ────► Enqueue to sink's queue
|
||||
// │
|
||||
// Yes (B→D has one)
|
||||
// │
|
||||
// ▼
|
||||
// Deserialize message JSON string → Order object using _sourceOutputType
|
||||
// │
|
||||
// ▼
|
||||
// Evaluate _condition(order) order => order.NeedsReview
|
||||
// │
|
||||
// ┌──┴──┐
|
||||
// true false
|
||||
// │ │
|
||||
// ▼ └──► Skip (log and return, D will not run)
|
||||
// Enqueue to
|
||||
// D's queue
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters;
|
||||
|
||||
/// <summary>
|
||||
/// Routes messages from a source executor to a single target executor with optional condition evaluation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Created by <see cref="DurableEdgeMap"/> during construction — one instance per (source, sink) edge.
|
||||
/// When an edge has a condition (e.g., <c>order => order.Total > 1000</c>), the router deserialises
|
||||
/// the serialised JSON message back to the source executor's output type so the condition delegate
|
||||
/// can evaluate it against strongly-typed properties. If the condition returns <c>false</c>, the
|
||||
/// message is not forwarded and the target executor will not run for this edge.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For sources with multiple successors, individual <see cref="DurableDirectEdgeRouter"/> instances
|
||||
/// are wrapped in a <see cref="DurableFanOutEdgeRouter"/> so a single <c>RouteMessage</c> call
|
||||
/// fans the same message out to all targets, each evaluating its own condition independently.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class DurableDirectEdgeRouter : IDurableEdgeRouter
|
||||
{
|
||||
private readonly string _sourceId;
|
||||
private readonly string _sinkId;
|
||||
private readonly Func<object?, bool>? _condition;
|
||||
private readonly Type? _sourceOutputType;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="DurableDirectEdgeRouter"/>.
|
||||
/// </summary>
|
||||
/// <param name="sourceId">The source executor ID.</param>
|
||||
/// <param name="sinkId">The target executor ID.</param>
|
||||
/// <param name="condition">Optional condition function to evaluate before routing.</param>
|
||||
/// <param name="sourceOutputType">The output type of the source executor for deserialization.</param>
|
||||
internal DurableDirectEdgeRouter(
|
||||
string sourceId,
|
||||
string sinkId,
|
||||
Func<object?, bool>? condition,
|
||||
Type? sourceOutputType)
|
||||
{
|
||||
this._sourceId = sourceId;
|
||||
this._sinkId = sinkId;
|
||||
this._condition = condition;
|
||||
this._sourceOutputType = sourceOutputType;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RouteMessage(
|
||||
DurableMessageEnvelope envelope,
|
||||
Dictionary<string, Queue<DurableMessageEnvelope>> messageQueues,
|
||||
ILogger logger)
|
||||
{
|
||||
if (this._condition is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
object? messageObj = DeserializeForCondition(envelope.Message, this._sourceOutputType);
|
||||
if (!this._condition(messageObj))
|
||||
{
|
||||
logger.LogEdgeConditionFalse(this._sourceId, this._sinkId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogEdgeConditionEvaluationFailed(ex, this._sourceId, this._sinkId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogEdgeRoutingMessage(this._sourceId, this._sinkId);
|
||||
EnqueueMessage(messageQueues, this._sinkId, envelope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a JSON message to an object for condition evaluation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Messages travel through the durable workflow as serialized JSON strings, but condition
|
||||
/// delegates need typed objects to evaluate (e.g., order => order.Status == "Approved").
|
||||
/// This method converts the JSON back to an object the condition delegate can evaluate.
|
||||
/// </remarks>
|
||||
/// <param name="json">The JSON string representation of the message.</param>
|
||||
/// <param name="targetType">
|
||||
/// The expected type of the message. When provided, enables strongly-typed deserialization
|
||||
/// so the condition function receives the correct type to evaluate against.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The deserialized object, or null if the JSON is empty.
|
||||
/// </returns>
|
||||
/// <exception cref="JsonException">Thrown when the JSON is invalid or cannot be deserialized to the target type.</exception>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
|
||||
private static object? DeserializeForCondition(string json, Type? targetType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we know the source executor's output type, deserialize to that specific type
|
||||
// so the condition function can access strongly-typed properties.
|
||||
// Otherwise, deserialize as a generic object for basic inspection.
|
||||
return targetType is null
|
||||
? JsonSerializer.Deserialize<object>(json)
|
||||
: JsonSerializer.Deserialize(json, targetType);
|
||||
}
|
||||
|
||||
private static void EnqueueMessage(
|
||||
Dictionary<string, Queue<DurableMessageEnvelope>> queues,
|
||||
string executorId,
|
||||
DurableMessageEnvelope envelope)
|
||||
{
|
||||
if (!queues.TryGetValue(executorId, out Queue<DurableMessageEnvelope>? queue))
|
||||
{
|
||||
queue = new Queue<DurableMessageEnvelope>();
|
||||
queues[executorId] = queue;
|
||||
}
|
||||
|
||||
queue.Enqueue(envelope);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user