Merge main into feat/durable_task and resolve conflicts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Shyju Krishnankutty
2026-03-03 11:52:22 -08:00
co-authored by Copilot
2558 changed files with 147431 additions and 78783 deletions
@@ -7,14 +7,14 @@ namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents an event triggered when an agent produces a response.
/// </summary>
public class AgentResponseEvent : ExecutorEvent
public sealed class AgentResponseEvent : WorkflowOutputEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponseEvent"/> class.
/// </summary>
/// <param name="executorId">The identifier of the executor that generated this event.</param>
/// <param name="response">The agent response.</param>
public AgentResponseEvent(string executorId, AgentResponse response) : base(executorId, data: response)
public AgentResponseEvent(string executorId, AgentResponse response) : base(response, executorId)
{
this.Response = Throw.IfNull(response);
}
@@ -8,14 +8,14 @@ namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents an event triggered when an agent run produces an update.
/// </summary>
public class AgentResponseUpdateEvent : ExecutorEvent
public sealed class AgentResponseUpdateEvent : WorkflowOutputEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponseUpdateEvent"/> class.
/// </summary>
/// <param name="executorId">The identifier of the executor that generated this event.</param>
/// <param name="update">The agent run response update.</param>
public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update) : base(executorId, data: update)
public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update) : base(update, executorId)
{
this.Update = Throw.IfNull(update);
}
@@ -135,7 +135,7 @@ public static partial class AgentWorkflowBuilder
ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId);
builder.AddFanInEdge(accumulators, end);
builder.AddFanInBarrierEdge(accumulators, end);
builder = builder.WithOutputFrom(end);
if (workflowName is not null)
@@ -29,7 +29,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public sealed class SendsMessageAttribute : Attribute
{
/// <summary>
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Declares that an executor may yield messages of the specified type as workflow outputs.
/// </summary>
/// <remarks>
/// <para>
/// Apply this attribute to an <see cref="Executor"/> class to declare the types of messages
/// it may yield via <see cref="IWorkflowContext.YieldOutputAsync"/>. This information is used
/// for protocol validation and documentation.
/// </para>
/// <para>
/// This attribute can be applied multiple times to declare multiple output types.
/// It is inherited by derived classes, allowing base executors to declare common output types.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// [YieldsMessage(typeof(FinalResult))]
/// [YieldsMessage(typeof(StreamChunk))]
/// public partial class MyExecutor : Executor
/// {
/// // ...
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class YieldsMessageAttribute : Attribute
{
/// <summary>
/// Gets the type of message that the executor may yield.
/// </summary>
public Type Type { get; }
/// <summary>
/// Initializes a new instance of the <see cref="YieldsMessageAttribute"/> class.
/// </summary>
/// <param name="type">The type of message that the executor may yield.</param>
/// <exception cref="ArgumentNullException"><paramref name="type"/> is <see langword="null"/>.</exception>
public YieldsMessageAttribute(Type type)
{
this.Type = Throw.IfNull(type);
}
}
@@ -29,7 +29,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public sealed class YieldsOutputAttribute : Attribute
{
/// <summary>
@@ -34,19 +34,29 @@ public sealed class ChatForwardingExecutor(string id, ChatForwardingExecutorOpti
private readonly ChatRole? _stringMessageChatRole = options?.StringMessageChatRole;
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
if (this._stringMessageChatRole.HasValue)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message)));
}
return protocolBuilder.ConfigureRoutes(ConfigureRoutes)
.SendsMessage<ChatMessage>()
.SendsMessage<List<ChatMessage>>()
.SendsMessage<ChatMessage[]>()
.SendsMessage<TurnToken>();
return routeBuilder.AddHandler<ChatMessage>(ForwardMessageAsync)
.AddHandler<IEnumerable<ChatMessage>>(ForwardMessagesAsync)
.AddHandler<ChatMessage[]>(ForwardMessagesAsync)
.AddHandler<List<ChatMessage>>(ForwardMessagesAsync)
.AddHandler<TurnToken>(ForwardTurnTokenAsync);
void ConfigureRoutes(RouteBuilder routeBuilder)
{
if (this._stringMessageChatRole.HasValue)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message)));
}
routeBuilder.AddHandler<ChatMessage>(ForwardMessageAsync)
.AddHandler<IEnumerable<ChatMessage>>(ForwardMessagesAsync)
// remove this once we internalize the typecheck logic
.AddHandler<ChatMessage[]>(ForwardMessagesAsync)
//.AddHandler<List<ChatMessage>>(ForwardMessagesAsync)
.AddHandler<TurnToken>(ForwardTurnTokenAsync);
}
}
private static ValueTask ForwardMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken)
@@ -26,7 +26,7 @@ public static class ChatProtocolExtensions
/// langword="false"/>.</returns>
public static bool IsChatProtocol(this ProtocolDescriptor descriptor, bool allowCatchAll = false)
{
bool foundListChatMessageInput = false;
bool foundIEnumerableChatMessageInput = false;
bool foundTurnTokenInput = false;
if (allowCatchAll && descriptor.AcceptsAll)
@@ -40,9 +40,9 @@ public static class ChatProtocolExtensions
// output type.
foreach (Type inputType in descriptor.Accepts)
{
if (inputType == typeof(List<ChatMessage>))
if (inputType == typeof(IEnumerable<ChatMessage>))
{
foundListChatMessageInput = true;
foundIEnumerableChatMessageInput = true;
}
else if (inputType == typeof(TurnToken))
{
@@ -50,7 +50,7 @@ public static class ChatProtocolExtensions
}
}
return foundListChatMessageInput && foundTurnTokenInput;
return foundIEnumerableChatMessageInput && foundTurnTokenInput;
}
/// <summary>
@@ -67,19 +67,26 @@ public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
protected bool AutoSendTurnToken => this._options.AutoSendTurnToken;
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
if (this.SupportsStringMessage)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => this.AddMessageAsync(new(this.StringMessageChatRole.Value, message), context));
}
return protocolBuilder.ConfigureRoutes(ConfigureRoutes)
.SendsMessage<List<ChatMessage>>()
.SendsMessage<TurnToken>();
return routeBuilder.AddHandler<ChatMessage>(this.AddMessageAsync)
.AddHandler<IEnumerable<ChatMessage>>(this.AddMessagesAsync)
.AddHandler<ChatMessage[]>(this.AddMessagesAsync)
.AddHandler<List<ChatMessage>>(this.AddMessagesAsync)
.AddHandler<TurnToken>(this.TakeTurnAsync);
void ConfigureRoutes(RouteBuilder routeBuilder)
{
if (this.SupportsStringMessage)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => this.AddMessageAsync(new(this.StringMessageChatRole.Value, message), context));
}
routeBuilder.AddHandler<ChatMessage>(this.AddMessageAsync)
.AddHandler<IEnumerable<ChatMessage>>(this.AddMessagesAsync)
.AddHandler<ChatMessage[]>(this.AddMessagesAsync)
//.AddHandler<List<ChatMessage>>(this.AddMessagesAsync)
.AddHandler<TurnToken>(this.TakeTurnAsync);
}
}
/// <summary>
@@ -7,14 +7,14 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents a checkpoint with a unique identifier and a timestamp indicating when it was created.
/// Represents a checkpoint with a unique identifier.
/// </summary>
public sealed class CheckpointInfo : IEquatable<CheckpointInfo>
{
/// <summary>
/// Gets the unique identifier for the current run.
/// Gets the unique identifier for the current session.
/// </summary>
public string RunId { get; }
public string SessionId { get; }
/// <summary>
/// The unique identifier for the checkpoint.
@@ -22,37 +22,34 @@ public sealed class CheckpointInfo : IEquatable<CheckpointInfo>
public string CheckpointId { get; }
/// <summary>
/// Initializes a new instance of the <see cref="CheckpointInfo"/> class with a unique identifier and the current
/// UTC timestamp.
/// Initializes a new instance of the <see cref="CheckpointInfo"/> class with a unique identifier.
/// </summary>
/// <remarks>This constructor generates a new unique identifier using a GUID in a 32-character, lowercase,
/// hexadecimal format and sets the timestamp to the current UTC time.</remarks>
internal CheckpointInfo(string runId) : this(runId, Guid.NewGuid().ToString("N")) { }
internal CheckpointInfo(string sessionId) : this(sessionId, Guid.NewGuid().ToString("N")) { }
/// <summary>
/// Initializes a new instance of the CheckpointInfo class with the specified run and checkpoint identifiers.
/// Initializes a new instance of the CheckpointInfo class with the specified session and checkpoint identifiers.
/// </summary>
/// <param name="runId">The unique identifier for the run. Cannot be null or empty.</param>
/// <param name="sessionId">The unique identifier for the session. Cannot be null or empty.</param>
/// <param name="checkpointId">The unique identifier for the checkpoint. Cannot be null or empty.</param>
[JsonConstructor]
public CheckpointInfo(string runId, string checkpointId)
public CheckpointInfo(string sessionId, string checkpointId)
{
this.RunId = Throw.IfNullOrEmpty(runId);
this.SessionId = Throw.IfNullOrEmpty(sessionId);
this.CheckpointId = Throw.IfNullOrEmpty(checkpointId);
}
/// <inheritdoc/>
public bool Equals(CheckpointInfo? other) =>
other is not null &&
this.RunId == other.RunId &&
this.SessionId == other.SessionId &&
this.CheckpointId == other.CheckpointId;
/// <inheritdoc/>
public override bool Equals(object? obj) => this.Equals(obj as CheckpointInfo);
/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(this.RunId, this.CheckpointId);
public override int GetHashCode() => HashCode.Combine(this.SessionId, this.CheckpointId);
/// <inheritdoc/>
public override string ToString() => $"CheckpointInfo(RunId: {this.RunId}, CheckpointId: {this.CheckpointId})";
public override string ToString() => $"CheckpointInfo(SessionId: {this.SessionId}, CheckpointId: {this.CheckpointId})";
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -49,9 +50,12 @@ public sealed class CheckpointManager : ICheckpointManager
return new(CreateImpl(marshaller, store));
}
ValueTask<CheckpointInfo> ICheckpointManager.CommitCheckpointAsync(string runId, Checkpoint checkpoint)
=> this._impl.CommitCheckpointAsync(runId, checkpoint);
ValueTask<CheckpointInfo> ICheckpointManager.CommitCheckpointAsync(string sessionId, Checkpoint checkpoint)
=> this._impl.CommitCheckpointAsync(sessionId, checkpoint);
ValueTask<Checkpoint> ICheckpointManager.LookupCheckpointAsync(string runId, CheckpointInfo checkpointInfo)
=> this._impl.LookupCheckpointAsync(runId, checkpointInfo);
ValueTask<Checkpoint> ICheckpointManager.LookupCheckpointAsync(string sessionId, CheckpointInfo checkpointInfo)
=> this._impl.LookupCheckpointAsync(sessionId, checkpointInfo);
ValueTask<IEnumerable<CheckpointInfo>> ICheckpointManager.RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent)
=> this._impl.RetrieveIndexAsync(sessionId, withParent);
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents a base object for a workflow run that may support checkpointing.
/// </summary>
public abstract class CheckpointableRunBase
{
// TODO: Rename Context?
private readonly ICheckpointingHandle _checkpointingHandle;
internal CheckpointableRunBase(ICheckpointingHandle checkpointingHandle)
{
this._checkpointingHandle = checkpointingHandle;
}
/// <inheritdoc cref="ICheckpointingHandle.IsCheckpointingEnabled"/>
public bool IsCheckpointingEnabled => this._checkpointingHandle.IsCheckpointingEnabled;
/// <inheritdoc cref="ICheckpointingHandle.Checkpoints"/>
public IReadOnlyList<CheckpointInfo> Checkpoints => this._checkpointingHandle.Checkpoints ?? [];
/// <summary>
/// Gets the most recent checkpoint information.
/// </summary>
public CheckpointInfo? LastCheckpoint
{
get
{
if (!this.IsCheckpointingEnabled)
{
return null;
}
var checkpoints = this.Checkpoints;
return checkpoints.Count > 0 ? checkpoints[checkpoints.Count - 1] : null;
}
}
/// <inheritdoc cref="ICheckpointingHandle.RestoreCheckpointAsync"/>
public ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default)
=> this._checkpointingHandle.RestoreCheckpointAsync(checkpointInfo, cancellationToken);
}
@@ -1,66 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents a workflow run that supports checkpointing.
/// </summary>
/// <typeparam name="TRun">The type of the underlying workflow run handle.</typeparam>
/// <seealso cref="Run"/>
/// <seealso cref="StreamingRun"/>
public sealed class Checkpointed<TRun> : IAsyncDisposable
{
private readonly ICheckpointingHandle _runner;
internal Checkpointed(TRun run, ICheckpointingHandle runner)
{
this.Run = Throw.IfNull(run);
this._runner = Throw.IfNull(runner);
}
/// <summary>
/// Gets the workflow run associated with this <see cref="Checkpointed{TRun}"/> instance.
/// </summary>
/// <seealso cref="Run"/>
/// <seealso cref="StreamingRun"/>
public TRun Run { get; }
/// <inheritdoc cref="ICheckpointingHandle.Checkpoints"/>
public IReadOnlyList<CheckpointInfo> Checkpoints => this._runner.Checkpoints;
/// <summary>
/// Gets the most recent checkpoint information.
/// </summary>
public CheckpointInfo? LastCheckpoint
{
get
{
var checkpoints = this.Checkpoints;
return checkpoints.Count > 0 ? checkpoints[checkpoints.Count - 1] : null;
}
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (this.Run is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync().ConfigureAwait(false);
}
else if (this.Run is IDisposable disposable)
{
disposable.Dispose();
}
}
/// <inheritdoc cref="ICheckpointingHandle.RestoreCheckpointAsync"/>
public ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default)
=> this._runner.RestoreCheckpointAsync(checkpointInfo, cancellationToken);
}
@@ -15,7 +15,7 @@ internal sealed partial class CheckpointInfoConverter() : JsonConverterDictionar
protected override JsonTypeInfo<CheckpointInfo> TypeInfo
=> WorkflowsJsonUtilities.JsonContext.Default.CheckpointInfo;
private const string CheckpointInfoPropertyNamePattern = @"^(?<runId>(((\|\|)|([^\|]))*))\|(?<checkpointId>(((\|\|)|([^\|]))*)?)$";
private const string CheckpointInfoPropertyNamePattern = @"^(?<sessionId>(((\|\|)|([^\|]))*))\|(?<checkpointId>(((\|\|)|([^\|]))*)?)$";
#if NET
[GeneratedRegex(CheckpointInfoPropertyNamePattern, RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture)]
public static partial Regex CheckpointInfoPropertyNameRegex();
@@ -33,17 +33,17 @@ internal sealed partial class CheckpointInfoConverter() : JsonConverterDictionar
throw new JsonException($"Invalid CheckpointInfo property name format. Got '{propertyName}'.");
}
string runId = scopeKeyPatternMatch.Groups["runId"].Value;
string sessionId = scopeKeyPatternMatch.Groups["sessionId"].Value;
string checkpointId = scopeKeyPatternMatch.Groups["checkpointId"].Value;
return new(Unescape(runId)!, Unescape(checkpointId)!);
return new(Unescape(sessionId)!, Unescape(checkpointId)!);
}
protected override string Stringify([DisallowNull] CheckpointInfo value)
{
string? runIdEscaped = Escape(value.RunId);
string? sessionIdEscaped = Escape(value.SessionId);
string? checkpointIdEscaped = Escape(value.CheckpointId);
return $"{runIdEscaped}|{checkpointIdEscaped}";
return $"{sessionIdEscaped}|{checkpointIdEscaped}";
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -15,16 +16,19 @@ internal sealed class CheckpointManagerImpl<TStoreObject> : ICheckpointManager
this._store = store;
}
public ValueTask<CheckpointInfo> CommitCheckpointAsync(string runId, Checkpoint checkpoint)
public ValueTask<CheckpointInfo> CommitCheckpointAsync(string sessionId, Checkpoint checkpoint)
{
TStoreObject storeObject = this._marshaller.Marshal(checkpoint);
return this._store.CreateCheckpointAsync(runId, storeObject, checkpoint.Parent);
return this._store.CreateCheckpointAsync(sessionId, storeObject, checkpoint.Parent);
}
public async ValueTask<Checkpoint> LookupCheckpointAsync(string runId, CheckpointInfo checkpointInfo)
public async ValueTask<Checkpoint> LookupCheckpointAsync(string sessionId, CheckpointInfo checkpointInfo)
{
TStoreObject result = await this._store.RetrieveCheckpointAsync(runId, checkpointInfo).ConfigureAwait(false);
TStoreObject result = await this._store.RetrieveCheckpointAsync(sessionId, checkpointInfo).ConfigureAwait(false);
return this._marshaller.Marshal<Checkpoint>(result);
}
public ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null)
=> this._store.RetrieveIndexAsync(sessionId, withParent);
}
@@ -93,15 +93,15 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
}
}
private string GetFileNameForCheckpoint(string runId, CheckpointInfo key)
=> Path.Combine(this.Directory.FullName, $"{runId}_{key.CheckpointId}.json");
private string GetFileNameForCheckpoint(string sessionId, CheckpointInfo key)
=> Path.Combine(this.Directory.FullName, $"{sessionId}_{key.CheckpointId}.json");
private CheckpointInfo GetUnusedCheckpointInfo(string runId)
private CheckpointInfo GetUnusedCheckpointInfo(string sessionId)
{
CheckpointInfo key;
do
{
key = new(runId);
key = new(sessionId);
} while (!this.CheckpointIndex.Add(key));
return key;
@@ -110,12 +110,12 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
/// <inheritdoc/>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1835:Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync'",
Justification = "Memory-based overload is missing for 4.7.2")]
public override async ValueTask<CheckpointInfo> CreateCheckpointAsync(string runId, JsonElement value, CheckpointInfo? parent = null)
public override async ValueTask<CheckpointInfo> CreateCheckpointAsync(string sessionId, JsonElement value, CheckpointInfo? parent = null)
{
this.CheckDisposed();
CheckpointInfo key = this.GetUnusedCheckpointInfo(runId);
string fileName = this.GetFileNameForCheckpoint(runId, key);
CheckpointInfo key = this.GetUnusedCheckpointInfo(sessionId);
string fileName = this.GetFileNameForCheckpoint(sessionId, key);
try
{
using Stream checkpointStream = File.Open(fileName, FileMode.Create, FileAccess.Write, FileShare.None);
@@ -145,10 +145,10 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
}
/// <inheritdoc/>
public override async ValueTask<JsonElement> RetrieveCheckpointAsync(string runId, CheckpointInfo key)
public override async ValueTask<JsonElement> RetrieveCheckpointAsync(string sessionId, CheckpointInfo key)
{
this.CheckDisposed();
string fileName = this.GetFileNameForCheckpoint(runId, key);
string fileName = this.GetFileNameForCheckpoint(sessionId, key);
if (!this.CheckpointIndex.Contains(key) ||
!File.Exists(fileName))
@@ -163,7 +163,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
}
/// <inheritdoc/>
public override ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string runId, CheckpointInfo? withParent = null)
public override ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null)
{
this.CheckDisposed();
@@ -13,18 +13,30 @@ internal interface ICheckpointManager
/// <summary>
/// Commits the specified checkpoint and returns information that can be used to retrieve it later.
/// </summary>
/// <param name="runId">The identifier for the current run or execution context.</param>
/// <param name="sessionId">The identifier for the current session or execution context.</param>
/// <param name="checkpoint">The checkpoint to commit.</param>
/// <returns>A <see cref="CheckpointInfo"/> representing the incoming checkpoint.</returns>
ValueTask<CheckpointInfo> CommitCheckpointAsync(string runId, Checkpoint checkpoint);
ValueTask<CheckpointInfo> CommitCheckpointAsync(string sessionId, Checkpoint checkpoint);
/// <summary>
/// Retrieves the checkpoint associated with the specified checkpoint information.
/// </summary>
/// <param name="runId">The identifier for the current run of execution context.</param>
/// <param name="sessionId">The identifier for the current session of execution context.</param>
/// <param name="checkpointInfo">The information used to identify the checkpoint.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> representing the asynchronous operation. The result contains the <see
/// cref="Checkpoint"/> associated with the specified <paramref name="checkpointInfo"/>.</returns>
/// <exception cref="KeyNotFoundException">Thrown if the checkpoint is not found.</exception>
ValueTask<Checkpoint> LookupCheckpointAsync(string runId, CheckpointInfo checkpointInfo);
ValueTask<Checkpoint> LookupCheckpointAsync(string sessionId, CheckpointInfo checkpointInfo);
/// <summary>
/// Asynchronously retrieves the collection of checkpoint information for the specified session identifier, optionally
/// filtered by a parent checkpoint.
/// </summary>
/// <param name="sessionId">The unique identifier of the session for which to retrieve checkpoint information. Cannot be null or empty.</param>
/// <param name="withParent">An optional parent checkpoint to filter the results. If specified, only checkpoints with the given parent are
/// returned; otherwise, all checkpoints for the session are included.</param>
/// <returns>A value task representing the asynchronous operation. The result contains a collection of <see
/// cref="CheckpointInfo"/> objects associated with the specified session. The collection is empty if no checkpoints are
/// found.</returns>
ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null);
}
@@ -6,44 +6,41 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
/// <summary>
/// Defines a contract for storing and retrieving checkpoints associated with a specific run and key.
/// Defines a contract for storing and retrieving checkpoints associated with a specific session and key.
/// </summary>
/// <remarks>Implementations of this interface enable durable or in-memory storage of checkpoints, which can be
/// used to resume or audit long-running processes. The interface is generic to support different storage object types
/// depending on the application's requirements.</remarks>
/// <typeparam name="TStoreObject">The type of object to be stored as the value for each checkpoint.</typeparam>
public interface ICheckpointStore<TStoreObject>
{
/// <summary>
/// Asynchronously retrieves the collection of checkpoint information for the specified run identifier, optionally
/// Asynchronously retrieves the collection of checkpoint information for the specified session identifier, optionally
/// filtered by a parent checkpoint.
/// </summary>
/// <param name="runId">The unique identifier of the run for which to retrieve checkpoint information. Cannot be null or empty.</param>
/// <param name="sessionId">The unique identifier of the session for which to retrieve checkpoint information. Cannot be null or empty.</param>
/// <param name="withParent">An optional parent checkpoint to filter the results. If specified, only checkpoints with the given parent are
/// returned; otherwise, all checkpoints for the run are included.</param>
/// returned; otherwise, all checkpoints for the session are included.</param>
/// <returns>A value task representing the asynchronous operation. The result contains a collection of <see
/// cref="CheckpointInfo"/> objects associated with the specified run. The collection is empty if no checkpoints are
/// cref="CheckpointInfo"/> objects associated with the specified session. The collection is empty if no checkpoints are
/// found.</returns>
ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string runId, CheckpointInfo? withParent = null);
ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null);
/// <summary>
/// Asynchronously creates a checkpoint for the specified run and key, associating it with the provided value and
/// Asynchronously creates a checkpoint for the specified session and key, associating it with the provided value and
/// optional parent checkpoint.
/// </summary>
/// <param name="runId">The unique identifier of the run for which the checkpoint is being created. Cannot be null or empty.</param>
/// <param name="sessionId">The unique identifier of the session for which the checkpoint is being created. Cannot be null or empty.</param>
/// <param name="value">The value to associate with the checkpoint. Cannot be null.</param>
/// <param name="parent">The optional parent checkpoint information. If specified, the new checkpoint will be linked as a child of this
/// parent.</param>
/// <returns>A ValueTask that represents the asynchronous operation. The result contains the <see cref="CheckpointInfo"/>
/// object representing this stored checkpoint.</returns>
ValueTask<CheckpointInfo> CreateCheckpointAsync(string runId, TStoreObject value, CheckpointInfo? parent = null);
ValueTask<CheckpointInfo> CreateCheckpointAsync(string sessionId, TStoreObject value, CheckpointInfo? parent = null);
/// <summary>
/// Asynchronously retrieves a checkpoint object associated with the specified run and checkpoint key.
/// Asynchronously retrieves a checkpoint object associated with the specified session and checkpoint key.
/// </summary>
/// <param name="runId">The unique identifier of the run for which the checkpoint is to be retrieved. Cannot be null or empty.</param>
/// <param name="sessionId">The unique identifier of the session for which the checkpoint is to be retrieved. Cannot be null or empty.</param>
/// <param name="key">The key identifying the specific checkpoint to retrieve. Cannot be null.</param>
/// <returns>A ValueTask that represents the asynchronous operation. The result contains the checkpoint object associated
/// with the specified run and key.</returns>
ValueTask<TStoreObject> RetrieveCheckpointAsync(string runId, CheckpointInfo key);
/// with the specified session and key.</returns>
ValueTask<TStoreObject> RetrieveCheckpointAsync(string sessionId, CheckpointInfo key);
}
@@ -8,8 +8,21 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing;
internal interface ICheckpointingHandle
{
// TODO: Convert this to a multi-timeline (e.g.: Live timeline + forks for orphaned checkpoints due to timetravel)
/// <summary>
/// Gets a value indicating whether checkpointing is enabled for the current operation or process.
/// </summary>
bool IsCheckpointingEnabled { get; }
/// <summary>
/// Gets a read-only list of checkpoint information associated with the current context.
/// </summary>
IReadOnlyList<CheckpointInfo> Checkpoints { get; }
/// <summary>
/// Restores the system state from the specified checkpoint asynchronously.
/// </summary>
/// <param name="checkpointInfo">The checkpoint information that identifies the state to restore. Cannot be null.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the restore operation.</param>
/// <returns>A <see cref="ValueTask"/> that represents the asynchronous restore operation.</returns>
ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default);
}
@@ -13,51 +13,54 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing;
internal sealed class InMemoryCheckpointManager : ICheckpointManager
{
[JsonInclude]
internal Dictionary<string, RunCheckpointCache<Checkpoint>> Store { get; } = [];
internal Dictionary<string, SessionCheckpointCache<Checkpoint>> Store { get; } = [];
public InMemoryCheckpointManager() { }
[JsonConstructor]
internal InMemoryCheckpointManager(Dictionary<string, RunCheckpointCache<Checkpoint>> store)
internal InMemoryCheckpointManager(Dictionary<string, SessionCheckpointCache<Checkpoint>> store)
{
this.Store = store;
}
private RunCheckpointCache<Checkpoint> GetRunStore(string runId)
private SessionCheckpointCache<Checkpoint> GetSessionStore(string sessionId)
{
if (!this.Store.TryGetValue(runId, out RunCheckpointCache<Checkpoint>? runStore))
if (!this.Store.TryGetValue(sessionId, out SessionCheckpointCache<Checkpoint>? sessionStore))
{
runStore = this.Store[runId] = new();
sessionStore = this.Store[sessionId] = new();
}
return runStore;
return sessionStore;
}
public ValueTask<CheckpointInfo> CommitCheckpointAsync(string runId, Checkpoint checkpoint)
public ValueTask<CheckpointInfo> CommitCheckpointAsync(string sessionId, Checkpoint checkpoint)
{
RunCheckpointCache<Checkpoint> runStore = this.GetRunStore(runId);
SessionCheckpointCache<Checkpoint> sessionStore = this.GetSessionStore(sessionId);
CheckpointInfo key;
do
{
key = new(runId);
} while (!runStore.Add(key, checkpoint));
key = new(sessionId);
} while (!sessionStore.Add(key, checkpoint));
return new(key);
}
public ValueTask<Checkpoint> LookupCheckpointAsync(string runId, CheckpointInfo checkpointInfo)
public ValueTask<Checkpoint> LookupCheckpointAsync(string sessionId, CheckpointInfo checkpointInfo)
{
if (!this.GetRunStore(runId).TryGet(checkpointInfo, out Checkpoint? value))
if (!this.GetSessionStore(sessionId).TryGet(checkpointInfo, out Checkpoint? value))
{
throw new KeyNotFoundException($"Could not retrieve checkpoint with id {checkpointInfo.CheckpointId} for run {runId}");
throw new KeyNotFoundException($"Could not retrieve checkpoint with id {checkpointInfo.CheckpointId} for session {sessionId}");
}
return new(value);
}
internal bool HasCheckpoints(string runId) => this.GetRunStore(runId).HasCheckpoints;
internal bool HasCheckpoints(string sessionId) => this.GetSessionStore(sessionId).HasCheckpoints;
public bool TryGetLastCheckpoint(string runId, [NotNullWhen(true)] out CheckpointInfo? checkpoint)
=> this.GetRunStore(runId).TryGetLastCheckpointInfo(out checkpoint);
public bool TryGetLastCheckpoint(string sessionId, [NotNullWhen(true)] out CheckpointInfo? checkpoint)
=> this.GetSessionStore(sessionId).TryGetLastCheckpointInfo(out checkpoint);
public ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null)
=> new(this.GetSessionStore(sessionId).CheckpointIndex.AsReadOnly());
}
@@ -18,11 +18,11 @@ public abstract class JsonCheckpointStore : ICheckpointStore<JsonElement>
protected static JsonTypeInfo<CheckpointInfo> KeyTypeInfo => WorkflowsJsonUtilities.JsonContext.Default.CheckpointInfo;
/// <inheritdoc/>
public abstract ValueTask<CheckpointInfo> CreateCheckpointAsync(string runId, JsonElement value, CheckpointInfo? parent = null);
public abstract ValueTask<CheckpointInfo> CreateCheckpointAsync(string sessionId, JsonElement value, CheckpointInfo? parent = null);
/// <inheritdoc/>
public abstract ValueTask<JsonElement> RetrieveCheckpointAsync(string runId, CheckpointInfo key);
public abstract ValueTask<JsonElement> RetrieveCheckpointAsync(string sessionId, CheckpointInfo key);
/// <inheritdoc/>
public abstract ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string runId, CheckpointInfo? withParent = null);
public abstract ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null);
}
@@ -13,7 +13,13 @@ internal sealed class JsonMarshaller : IWireMarshaller<JsonElement>
public JsonMarshaller(JsonSerializerOptions? serializerOptions = null)
{
this._internalOptions = new JsonSerializerOptions(WorkflowsJsonUtilities.DefaultOptions);
this._internalOptions = new JsonSerializerOptions(WorkflowsJsonUtilities.DefaultOptions)
{
// Propagate from the user-provided options if set; enables support for databases
// like PostgreSQL jsonb that do not preserve property order.
AllowOutOfOrderMetadataProperties = serializerOptions?.AllowOutOfOrderMetadataProperties is true,
};
this._internalOptions.Converters.Add(new PortableValueConverter(this));
this._internalOptions.Converters.Add(new ExecutorIdentityConverter());
this._internalOptions.Converters.Add(new ScopeKeyConverter());
@@ -25,6 +25,7 @@ internal sealed class PortableMessageEnvelope
{
this.MessageType = envelope.MessageType;
this.Message = new PortableValue(envelope.Message);
this.Source = envelope.Source;
this.TargetId = envelope.TargetId;
}
@@ -6,7 +6,7 @@ using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
internal sealed class RunCheckpointCache<TStoreObject>
internal sealed class SessionCheckpointCache<TStoreObject>
{
[JsonInclude]
internal List<CheckpointInfo> CheckpointIndex { get; } = [];
@@ -14,10 +14,10 @@ internal sealed class RunCheckpointCache<TStoreObject>
[JsonInclude]
internal Dictionary<CheckpointInfo, TStoreObject> Cache { get; } = [];
public RunCheckpointCache() { }
public SessionCheckpointCache() { }
[JsonConstructor]
internal RunCheckpointCache(List<CheckpointInfo> checkpointIndex, Dictionary<CheckpointInfo, TStoreObject> cache)
internal SessionCheckpointCache(List<CheckpointInfo> checkpointIndex, Dictionary<CheckpointInfo, TStoreObject> cache)
{
this.CheckpointIndex = checkpointIndex;
this.Cache = cache;
@@ -29,13 +29,13 @@ internal sealed class RunCheckpointCache<TStoreObject>
public bool IsInIndex(CheckpointInfo key) => this.Cache.ContainsKey(key);
public bool TryGet(CheckpointInfo key, [MaybeNullWhen(false)] out TStoreObject value) => this.Cache.TryGetValue(key, out value);
public CheckpointInfo Add(string runId, TStoreObject value)
public CheckpointInfo Add(string sessionId, TStoreObject value)
{
CheckpointInfo key;
do
{
key = new(runId);
key = new(sessionId);
} while (!this.Add(key, value));
return key;
@@ -16,7 +16,7 @@ public static class ConfigurationExtensions
/// <param name="configured">The existing configuration for the subject type to be upcast to its parent type. Cannot be null.</param>
/// <returns>A new <see cref="Configured{TParent}"/> instance that applies the original configuration logic to the parent type.</returns>
public static Configured<TParent> Super<TSubject, TParent>(this Configured<TSubject> configured) where TSubject : TParent
=> new(async (config, runId) => await configured.FactoryAsync(config, runId).ConfigureAwait(false), configured.Id, configured.Raw);
=> new(async (config, sessionId) => await configured.FactoryAsync(config, sessionId).ConfigureAwait(false), configured.Id, configured.Raw);
/// <summary>
/// Creates a new configuration that treats the subject as its base type, allowing configuration to be applied at
@@ -79,7 +79,7 @@ public class Configured<TSubject>(Func<Config, string, ValueTask<TSubject>> fact
/// Gets a "partially" applied factory function that only requires no parameters to create an instance of
/// <typeparamref name="TSubject"/> with the provided <see cref="Configuration"/> instance.
/// </summary>
internal Func<string, ValueTask<TSubject>> BoundFactoryAsync => (runId) => this.FactoryAsync(this.Configuration, runId);
internal Func<string, ValueTask<TSubject>> BoundFactoryAsync => (sessionId) => this.FactoryAsync(this.Configuration, sessionId);
}
/// <summary>
@@ -122,20 +122,20 @@ public class Configured<TSubject, TOptions>(Func<Config<TOptions>, string, Value
/// Gets a "partially" applied factory function that only requires no parameters to create an instance of
/// <typeparamref name="TSubject"/> with the provided <see cref="Configuration"/> instance.
/// </summary>
internal Func<string, ValueTask<TSubject>> BoundFactoryAsync => (runId) => this.CreateValidatingMemoizedFactory()(this.Configuration, runId);
internal Func<string, ValueTask<TSubject>> BoundFactoryAsync => (sessionId) => this.CreateValidatingMemoizedFactory()(this.Configuration, sessionId);
private Func<Config, string, ValueTask<TSubject>> CreateValidatingMemoizedFactory()
{
return FactoryAsync;
async ValueTask<TSubject> FactoryAsync(Config configuration, string runId)
async ValueTask<TSubject> FactoryAsync(Config configuration, string sessionId)
{
if (this.Id != configuration.Id)
{
throw new InvalidOperationException($"Requested instance ID '{configuration.Id}' does not match configured ID '{this.Id}'.");
}
TSubject subject = await this.FactoryAsync(this.Configuration, runId).ConfigureAwait(false);
TSubject subject = await this.FactoryAsync(this.Configuration, sessionId).ConfigureAwait(false);
if (this.Id is not null && subject is IIdentified identified && identified.Id != this.Id)
{
@@ -44,7 +44,9 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable
}
}
public string RunId => this._stepRunner.RunId;
public string SessionId => this._stepRunner.SessionId;
public bool IsCheckpointingEnabled => this._checkpointingHandle.IsCheckpointingEnabled;
public IReadOnlyList<CheckpointInfo> Checkpoints => this._checkpointingHandle.Checkpoints;
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -8,12 +7,6 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
internal static class AsyncRunHandleExtensions
{
public static async ValueTask<Checkpointed<TRunType>> WithCheckpointingAsync<TRunType>(this AsyncRunHandle runHandle, Func<ValueTask<TRunType>> prepareFunc)
{
TRunType run = await prepareFunc().ConfigureAwait(false);
return new Checkpointed<TRunType>(run, runHandle);
}
public static async ValueTask<StreamingRun> EnqueueAndStreamAsync<TInput>(this AsyncRunHandle runHandle, TInput input, CancellationToken cancellationToken = default)
{
await runHandle.EnqueueMessageAsync(input, cancellationToken).ConfigureAwait(false);
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
@@ -9,12 +10,9 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeData) :
EdgeRunner<DirectEdgeData>(runContext, edgeData)
{
private async ValueTask<Executor> FindRouterAsync(IStepTracer? tracer) => await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId, tracer)
.ConfigureAwait(false);
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer, CancellationToken cancellationToken)
{
using var activity = s_activitySource.StartActivity(ActivityNames.EdgeGroupProcess);
using var activity = this.StartActivity();
activity?
.SetTag(Tags.EdgeGroupType, nameof(DirectEdgeRunner))
.SetTag(Tags.MessageSourceId, this.EdgeData.SourceId)
@@ -35,8 +33,11 @@ internal sealed class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData
return null;
}
Executor target = await this.FindRouterAsync(stepTracer).ConfigureAwait(false);
if (target.CanHandle(envelope.MessageType))
Type? messageType = await this.GetMessageRuntimeTypeAsync(envelope, stepTracer, cancellationToken)
.ConfigureAwait(false);
Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId, stepTracer, cancellationToken).ConfigureAwait(false);
if (CanHandle(target, messageType))
{
activity?.SetEdgeRunnerDeliveryStatus(EdgeRunnerDeliveryStatus.Delivered);
return new DeliveryMapping(envelope, target);
@@ -4,6 +4,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -65,7 +66,7 @@ internal sealed class EdgeMap
this._stepTracer = stepTracer;
}
public ValueTask<DeliveryMapping?> PrepareDeliveryForEdgeAsync(Edge edge, MessageEnvelope message)
public ValueTask<DeliveryMapping?> PrepareDeliveryForEdgeAsync(Edge edge, MessageEnvelope message, CancellationToken cancellationToken = default)
{
EdgeId id = edge.Data.Id;
if (!this._edgeRunners.TryGetValue(id, out EdgeRunner? edgeRunner))
@@ -73,25 +74,25 @@ internal sealed class EdgeMap
throw new InvalidOperationException($"Edge {edge} not found in the edge map.");
}
return edgeRunner.ChaseEdgeAsync(message, this._stepTracer);
return edgeRunner.ChaseEdgeAsync(message, this._stepTracer, cancellationToken);
}
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)
public ValueTask<DeliveryMapping?> PrepareDeliveryForInputAsync(MessageEnvelope message, CancellationToken cancellationToken = default)
{
return this._inputRunner.ChaseEdgeAsync(message, this._stepTracer);
return this._inputRunner.ChaseEdgeAsync(message, this._stepTracer, cancellationToken);
}
public ValueTask<DeliveryMapping?> PrepareDeliveryForResponseAsync(ExternalResponse response)
public ValueTask<DeliveryMapping?> PrepareDeliveryForResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default)
{
if (!this._portEdgeRunners.TryGetValue(response.PortInfo.PortId, out ResponseEdgeRunner? portRunner))
{
throw new InvalidOperationException($"Port {response.PortInfo.PortId} not found in the edge map.");
}
return portRunner.ChaseEdgeAsync(new MessageEnvelope(response, ExecutorIdentity.None), this._stepTracer);
return portRunner.ChaseEdgeAsync(new MessageEnvelope(response, ExecutorIdentity.None), this._stepTracer, cancellationToken);
}
internal async ValueTask<Dictionary<EdgeId, PortableValue>> ExportStateAsync()
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
@@ -14,11 +16,7 @@ internal interface IStatefulEdgeRunner
internal abstract class EdgeRunner
{
protected static readonly string s_namespace = typeof(EdgeRunner).Namespace!;
protected static readonly ActivitySource s_activitySource = new(s_namespace);
// TODO: Can this be sync?
protected internal abstract ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer);
protected internal abstract ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer, CancellationToken cancellationToken = default);
}
internal abstract class EdgeRunner<TEdgeData>(
@@ -26,4 +24,47 @@ internal abstract class EdgeRunner<TEdgeData>(
{
protected IRunnerContext RunContext { get; } = Throw.IfNull(runContext);
protected TEdgeData EdgeData { get; } = Throw.IfNull(edgeData);
protected async ValueTask<ExecutorProtocol> FindSourceProtocolAsync(string sourceId, IStepTracer? stepTracer, CancellationToken cancellationToken = default)
{
Executor sourceExecutor = await this.RunContext.EnsureExecutorAsync(Throw.IfNull(sourceId), stepTracer, cancellationToken)
.ConfigureAwait(false);
return sourceExecutor.Protocol;
}
protected async ValueTask<Type?> GetMessageRuntimeTypeAsync(MessageEnvelope envelope, IStepTracer? stepTracer, CancellationToken cancellationToken = default)
{
// The only difficulty occurs when we have gone through a checkpoint cycle, because the messages turn into PortableValue objects.
if (envelope.Message is PortableValue portableValue)
{
if (envelope.SourceId == null)
{
return null;
}
ExecutorProtocol protocol = await this.FindSourceProtocolAsync(envelope.SourceId, stepTracer, cancellationToken).ConfigureAwait(false);
return protocol.SendTypeTranslator.MapTypeId(portableValue.TypeId);
}
return envelope.Message.GetType();
}
protected static bool CanHandle(Executor target, Type? runtimeType)
{
// If we have a runtimeType, this is either a non-serialized object, or we successfully mapped a PortableValue back to its original type.
// In either case, we can check if the target can handle that type. Alternatively, even if we do not have a type, if the target has a catch-all,
// we can still route to it, since it should be able to handle anything.
return runtimeType != null ? target.CanHandle(runtimeType) : target.Router.HasCatchAll;
}
protected async ValueTask<bool> CanHandleAsync(string candidateTargetId, Type? runtimeType, IStepTracer? stepTracer, CancellationToken cancellationToken = default)
{
Executor candidateTarget = await this.RunContext.EnsureExecutorAsync(Throw.IfNull(candidateTargetId), stepTracer, cancellationToken)
.ConfigureAwait(false);
return CanHandle(candidateTarget, runtimeType);
}
protected Activity? StartActivity() => this.RunContext.TelemetryContext.StartEdgeGroupProcessActivity();
}
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
@@ -15,11 +16,11 @@ internal sealed class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData e
{
private FanInEdgeState _state = new(edgeData);
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer, CancellationToken cancellationToken)
{
Debug.Assert(!envelope.IsExternal, "FanIn edges should never be chased from external input");
using var activity = s_activitySource.StartActivity(ActivityNames.EdgeGroupProcess);
using var activity = this.StartActivity();
activity?
.SetTag(Tags.EdgeGroupType, nameof(FanInEdgeRunner))
.SetTag(Tags.MessageTargetId, this.EdgeData.SinkId);
@@ -31,7 +32,7 @@ internal sealed class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData e
}
// source.Id is guaranteed to be non-null here because source is not None.
IEnumerable<MessageEnvelope>? releasedMessages = this._state.ProcessMessage(envelope.SourceId, envelope);
List<IGrouping<ExecutorIdentity, MessageEnvelope>>? releasedMessages = this._state.ProcessMessage(envelope.SourceId, envelope)?.ToList();
if (releasedMessages is null)
{
// Not ready to process yet.
@@ -41,11 +42,22 @@ internal sealed class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData e
try
{
// TODO: Filter messages based on accepted input types?
Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId, stepTracer)
// Right now, for serialization purposes every message through FanInEdge goes through the PortableMessageEnvelope state, meaning
// we lose type information for all of them, potentially.
(ExecutorProtocol, IGrouping<ExecutorIdentity, MessageEnvelope>)[]
protocolGroupings = await Task.WhenAll(releasedMessages.Select(MapProtocolsAsync))
.ConfigureAwait(false);
IEnumerable<(Type? RuntimeType, MessageEnvelope MessageEnvelope)>
typedEnvelopes = protocolGroupings.SelectMany(MapRuntimeTypes);
Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId, stepTracer, cancellationToken)
.ConfigureAwait(false);
// Materialize the filtered list via ToList() to avoid multiple enumerations
var finalReleasedMessages = releasedMessages.Where(envelope => target.CanHandle(envelope.MessageType)).ToList();
List<MessageEnvelope> finalReleasedMessages = typedEnvelopes.Where(te => CanHandle(target, te.RuntimeType))
.Select(te => te.MessageEnvelope)
.ToList();
if (finalReleasedMessages.Count == 0)
{
activity?.SetEdgeRunnerDeliveryStatus(EdgeRunnerDeliveryStatus.DroppedTypeMismatch);
@@ -53,6 +65,28 @@ internal sealed class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData e
}
return new DeliveryMapping(finalReleasedMessages, target);
async Task<(ExecutorProtocol, IGrouping<ExecutorIdentity, MessageEnvelope>)> MapProtocolsAsync(IGrouping<ExecutorIdentity, MessageEnvelope> grouping)
{
ExecutorProtocol protocol = await this.FindSourceProtocolAsync(grouping.Key.Id!, stepTracer, cancellationToken).ConfigureAwait(false);
return (protocol, grouping);
}
IEnumerable<(Type?, MessageEnvelope)> MapRuntimeTypes((ExecutorProtocol, IGrouping<ExecutorIdentity, MessageEnvelope>) input)
{
(ExecutorProtocol protocol, IGrouping<ExecutorIdentity, MessageEnvelope> grouping) = input;
return grouping.Select(envelope => (ResolveEnvelopeType(envelope), envelope));
Type? ResolveEnvelopeType(MessageEnvelope messageEnvelope)
{
if (messageEnvelope.Message is PortableValue portableValue)
{
return protocol.SendTypeTranslator.MapTypeId(portableValue.TypeId);
}
return messageEnvelope.Message.GetType();
}
}
}
catch (Exception) when (activity is not null)
{
@@ -32,7 +32,7 @@ internal sealed class FanInEdgeState
this._pendingMessages = pendingMessages;
}
public IEnumerable<MessageEnvelope>? ProcessMessage(string sourceId, MessageEnvelope envelope)
public IEnumerable<IGrouping<ExecutorIdentity, MessageEnvelope>>? ProcessMessage(string sourceId, MessageEnvelope envelope)
{
this.PendingMessages.Add(new(envelope));
this.Unseen.Remove(sourceId);
@@ -47,7 +47,8 @@ internal sealed class FanInEdgeState
return null;
}
return takenMessages.Select(portable => portable.ToMessageEnvelope());
return takenMessages.Select(portable => portable.ToMessageEnvelope())
.GroupBy(keySelector: messageEnvelope => messageEnvelope.Source);
}
return null;
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
@@ -11,9 +12,9 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeData) :
EdgeRunner<FanOutEdgeData>(runContext, edgeData)
{
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer, CancellationToken cancellationToken)
{
using var activity = s_activitySource.StartActivity(ActivityNames.EdgeGroupProcess);
using var activity = this.StartActivity();
activity?
.SetTag(Tags.EdgeGroupType, nameof(FanOutEdgeRunner))
.SetTag(Tags.MessageSourceId, this.EdgeData.SourceId);
@@ -39,7 +40,10 @@ internal sealed class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData
return null;
}
IEnumerable<Executor> validTargets = result.Where(t => t.CanHandle(envelope.MessageType));
Type? runtimeType = await this.GetMessageRuntimeTypeAsync(envelope, stepTracer, cancellationToken)
.ConfigureAwait(false);
IEnumerable<Executor> validTargets = result.Where(t => CanHandle(t, runtimeType));
if (!validTargets.Any())
{
@@ -3,11 +3,14 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
namespace Microsoft.Agents.AI.Workflows.Execution;
internal interface IRunnerContext : IExternalRequestSink, ISuperStepJoinContext
{
WorkflowTelemetryContext TelemetryContext { get; }
ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default);
ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null, CancellationToken cancellationToken = default);
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
internal interface ISuperStepJoinContext
{
bool WithCheckpointing { get; }
bool IsCheckpointingEnabled { get; }
bool ConcurrentRunsEnabled { get; }
ValueTask ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default);
@@ -3,15 +3,18 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
namespace Microsoft.Agents.AI.Workflows.Execution;
internal interface ISuperStepRunner
{
string RunId { get; }
string SessionId { get; }
string StartExecutorId { get; }
WorkflowTelemetryContext TelemetryContext { get; }
bool HasUnservicedRequests { get; }
bool HasUnprocessedMessages { get; }
@@ -13,14 +13,12 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class LockstepRunEventStream : IRunEventStream
{
private static readonly string s_namespace = typeof(LockstepRunEventStream).Namespace!;
private static readonly ActivitySource s_activitySource = new(s_namespace);
private readonly CancellationTokenSource _stopCancellation = new();
private readonly InputWaiter _inputWaiter = new();
private int _isDisposed;
private readonly ISuperStepRunner _stepRunner;
private Activity? _sessionActivity;
public ValueTask<RunStatus> GetStatusAsync(CancellationToken cancellationToken = default) => new(this.RunStatus);
@@ -33,7 +31,16 @@ internal sealed class LockstepRunEventStream : IRunEventStream
public void Start()
{
// No-op for lockstep execution
// Save and restore Activity.Current so the long-lived session activity
// doesn't leak into caller code via AsyncLocal.
Activity? previousActivity = Activity.Current;
this._sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity();
this._sessionActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
this._sessionActivity?.AddEvent(new ActivityEvent(EventNames.SessionStarted));
Activity.Current = previousActivity;
}
public async IAsyncEnumerable<WorkflowEvent> TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default)
@@ -47,19 +54,23 @@ internal sealed class LockstepRunEventStream : IRunEventStream
}
#endif
CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken);
using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken);
ConcurrentQueue<WorkflowEvent> eventSink = [];
this._stepRunner.OutgoingEvents.EventRaised += OnWorkflowEventAsync;
using Activity? activity = s_activitySource.StartActivity(ActivityNames.WorkflowRun);
activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.RunId, this._stepRunner.RunId);
// Re-establish session as parent so the run activity nests correctly.
Activity.Current = this._sessionActivity;
// Not 'using' — must dispose explicitly in finally for deterministic export.
Activity? runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId);
try
{
this.RunStatus = RunStatus.Running;
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
do
{
@@ -68,7 +79,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream
{
// Because we may be yielding out of this function, we need to ensure that the Activity.Current
// is set to our activity for the duration of this loop iteration.
Activity.Current = activity;
Activity.Current = runActivity;
// Drain SuperSteps while there are steps to run
try
@@ -78,13 +89,13 @@ internal sealed class LockstepRunEventStream : IRunEventStream
catch (OperationCanceledException)
{
}
catch (Exception ex) when (activity is not null)
catch (Exception ex) when (runActivity is not null)
{
activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
{ Tags.ErrorType, ex.GetType().FullName },
{ Tags.BuildErrorMessage, ex.Message },
{ Tags.ErrorMessage, ex.Message },
}));
activity.CaptureException(ex);
runActivity.CaptureException(ex);
throw;
}
@@ -132,12 +143,16 @@ internal sealed class LockstepRunEventStream : IRunEventStream
}
} while (!ShouldBreak());
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
}
finally
{
this.RunStatus = this._stepRunner.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle;
this._stepRunner.OutgoingEvents.EventRaised -= OnWorkflowEventAsync;
// Explicitly dispose the Activity so Activity.Stop fires deterministically,
// regardless of how the async iterator enumerator is disposed.
runActivity?.Dispose();
}
ValueTask OnWorkflowEventAsync(object? sender, WorkflowEvent e)
@@ -175,6 +190,14 @@ internal sealed class LockstepRunEventStream : IRunEventStream
{
this._stopCancellation.Cancel();
// Stop the session activity
if (this._sessionActivity is not null)
{
this._sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionCompleted));
this._sessionActivity.Dispose();
this._sessionActivity = null;
}
this._stopCancellation.Dispose();
this._inputWaiter.Dispose();
}
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
@@ -27,8 +29,24 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class MessageRouter
{
private readonly Dictionary<Type, MessageHandlerF> _typedHandlers;
private readonly Dictionary<TypeId, Type> _runtimeTypeMap;
private readonly Type[] _interfaceHandlers;
//private readonly Dictionary<Type, MessageHandlerF> _typedHandlers;
//private readonly Dictionary<TypeId, Type> _runtimeTypeMap = new();
private readonly ConcurrentDictionary<TypeId, TypeHandlingInfo> _typeInfos = new();
private record TypeHandlingInfo(Type RuntimeType, MessageHandlerF Handler)
{
[Conditional("DEBUG")]
private void AssertTypeCovaraince(Type expectedDerviedType) => Debug.Assert(this.RuntimeType.IsAssignableFrom(expectedDerviedType));
public TypeHandlingInfo ForDerviedType(Type derivedType)
{
this.AssertTypeCovaraince(derivedType);
return this with { RuntimeType = derivedType };
}
}
private readonly CatchAllF? _catchAllFunc;
@@ -36,8 +54,18 @@ internal sealed class MessageRouter
{
Throw.IfNull(handlers);
this._typedHandlers = handlers;
this._runtimeTypeMap = handlers.Keys.ToDictionary(t => new TypeId(t), t => t);
HashSet<Type> interfaceHandlers = new();
foreach (Type type in handlers.Keys)
{
this._typeInfos[new(type)] = new(type, handlers[type]);
if (type.IsInterface)
{
interfaceHandlers.Add(type);
}
}
this._interfaceHandlers = interfaceHandlers.ToArray();
this._catchAllFunc = catchAllFunc;
this.IncomingTypes = [.. handlers.Keys];
@@ -49,16 +77,44 @@ internal sealed class MessageRouter
[MemberNotNullWhen(true, nameof(_catchAllFunc))]
internal bool HasCatchAll => this._catchAllFunc is not null;
public bool CanHandle(object message) => this.CanHandle(new TypeId(Throw.IfNull(message).GetType()));
public bool CanHandle(Type candidateType) => this.CanHandle(new TypeId(Throw.IfNull(candidateType)));
public bool CanHandle(TypeId candidateType)
{
return this.HasCatchAll || this._runtimeTypeMap.ContainsKey(candidateType);
}
public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType());
public bool CanHandle(Type candidateType) => this.HasCatchAll || this.FindHandler(candidateType) is not null;
public HashSet<Type> DefaultOutputTypes { get; }
private MessageHandlerF? FindHandler(Type messageType)
{
for (Type? candidateType = messageType; candidateType != null; candidateType = candidateType.BaseType)
{
TypeId candidateTypeId = new(candidateType);
if (this._typeInfos.TryGetValue(candidateTypeId, out TypeHandlingInfo? handlingInfo))
{
if (candidateType != messageType)
{
TypeHandlingInfo actualInfo = handlingInfo.ForDerviedType(messageType);
this._typeInfos.TryAdd(new(messageType), actualInfo);
}
return handlingInfo.Handler;
}
else if (this._interfaceHandlers.Length > 0)
{
foreach (Type interfaceType in this._interfaceHandlers.Where(it => it.IsAssignableFrom(candidateType)))
{
handlingInfo = this._typeInfos[new(interfaceType)];
// By definition we do not have a pre-calculated handler information for this candidateType, otherwise
// we would have found it above. This also means we do not have a corresponding entry for the messageType.
this._typeInfos.TryAdd(new(messageType), handlingInfo.ForDerviedType(messageType));
return handlingInfo.Handler;
}
}
}
return null;
}
public async ValueTask<CallResult?> RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false, CancellationToken cancellationToken = default)
{
Throw.IfNull(message);
@@ -67,15 +123,16 @@ internal sealed class MessageRouter
PortableValue? portableValue = message as PortableValue;
if (portableValue != null &&
this._runtimeTypeMap.TryGetValue(portableValue.TypeId, out Type? runtimeType))
this._typeInfos.TryGetValue(portableValue.TypeId, out TypeHandlingInfo? handlingInfo))
{
// If we found a runtime type, we can use it
message = portableValue.AsType(runtimeType) ?? message;
message = portableValue.AsType(handlingInfo.RuntimeType) ?? message;
}
try
{
if (this._typedHandlers.TryGetValue(message.GetType(), out MessageHandlerF? handler))
MessageHandlerF? handler = this.FindHandler(message.GetType());
if (handler != null)
{
result = await handler(message, context, cancellationToken).ConfigureAwait(false);
}
@@ -2,6 +2,7 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
using Microsoft.Shared.Diagnostics;
@@ -21,11 +22,11 @@ internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string execu
public string ExecutorId => executorId;
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer, CancellationToken cancellationToken)
{
Debug.Assert(envelope.IsExternal, "Input edges should only be chased from external input");
using var activity = s_activitySource.StartActivity(ActivityNames.EdgeGroupProcess);
using var activity = this.StartActivity();
activity?
.SetTag(Tags.EdgeGroupType, nameof(ResponseEdgeRunner))
.SetTag(Tags.MessageSourceId, envelope.SourceId)
@@ -34,7 +35,10 @@ internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string execu
try
{
Executor target = await this.FindExecutorAsync(stepTracer).ConfigureAwait(false);
if (target.CanHandle(envelope.MessageType))
Type? runtimeType = await this.GetMessageRuntimeTypeAsync(envelope, stepTracer, cancellationToken).ConfigureAwait(false);
if (CanHandle(target, runtimeType))
{
activity?.SetEdgeRunnerDeliveryStatus(EdgeRunnerDeliveryStatus.Delivered);
return new DeliveryMapping(envelope, target);
@@ -17,9 +17,6 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
/// </summary>
internal sealed class StreamingRunEventStream : IRunEventStream
{
private static readonly string s_namespace = typeof(StreamingRunEventStream).Namespace!;
private static readonly ActivitySource s_activitySource = new(s_namespace);
private readonly Channel<WorkflowEvent> _eventChannel;
private readonly ISuperStepRunner _stepRunner;
private readonly InputWaiter _inputWaiter;
@@ -58,13 +55,20 @@ internal sealed class StreamingRunEventStream : IRunEventStream
private async Task RunLoopAsync(CancellationToken cancellationToken)
{
using CancellationTokenSource errorSource = new();
CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(errorSource.Token, cancellationToken);
using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(errorSource.Token, cancellationToken);
// Subscribe to events - they will flow directly to the channel as they're raised
this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync;
using Activity? activity = s_activitySource.StartActivity(ActivityNames.WorkflowRun);
activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.RunId, this._stepRunner.RunId);
// Start the session-level activity that spans the entire run loop lifetime.
// Individual run-stage activities are nested within this session activity.
Activity? sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity();
sessionActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
Activity? runActivity = null;
sessionActivity?.AddEvent(new ActivityEvent(EventNames.SessionStarted));
try
{
@@ -73,10 +77,15 @@ internal sealed class StreamingRunEventStream : IRunEventStream
await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false);
this._runStatus = RunStatus.Running;
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
while (!linkedSource.Token.IsCancellationRequested)
{
// Start a new run-stage activity for this input→processing→halt cycle
runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
// Run all available supersteps continuously
// Events are streamed out in real-time as they happen via the event handler
while (this._stepRunner.HasUnprocessedMessages && !linkedSource.Token.IsCancellationRequested)
@@ -96,6 +105,15 @@ internal sealed class StreamingRunEventStream : IRunEventStream
RunStatus capturedStatus = this._runStatus;
await this._eventChannel.Writer.WriteAsync(new InternalHaltSignal(currentEpoch, capturedStatus), linkedSource.Token).ConfigureAwait(false);
// Close the run-stage activity when processing halts.
// A new run activity will be created when the next input arrives.
if (runActivity is not null)
{
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
runActivity.Dispose();
runActivity = null;
}
// Wait for next input from the consumer
// Works for both Idle (no work) and PendingRequests (waiting for responses)
await this._inputWaiter.WaitForInputAsync(TimeSpan.FromSeconds(1), linkedSource.Token).ConfigureAwait(false);
@@ -110,14 +128,26 @@ internal sealed class StreamingRunEventStream : IRunEventStream
}
catch (Exception ex)
{
if (activity != null)
// Record error on the run-stage activity if one is active
if (runActivity is not null)
{
activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
{ Tags.ErrorType, ex.GetType().FullName },
{ Tags.BuildErrorMessage, ex.Message },
{ Tags.ErrorMessage, ex.Message },
}));
activity.CaptureException(ex);
runActivity.CaptureException(ex);
}
// Record error on the session activity
if (sessionActivity is not null)
{
sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionError, tags: new() {
{ Tags.ErrorType, ex.GetType().FullName },
{ Tags.ErrorMessage, ex.Message },
}));
sessionActivity.CaptureException(ex);
}
await this._eventChannel.Writer.WriteAsync(new WorkflowErrorEvent(ex), linkedSource.Token).ConfigureAwait(false);
}
finally
@@ -127,7 +157,20 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Mark as ended when run loop exits
this._runStatus = RunStatus.Ended;
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
// Stop the run-stage activity if not already stopped (e.g. on cancellation or error)
if (runActivity is not null)
{
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
runActivity.Dispose();
}
// Stop the session activity — the session always ends when the run loop exits
if (sessionActivity is not null)
{
sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionCompleted));
sessionActivity.Dispose();
}
}
async ValueTask OnEventRaisedAsync(object? sender, WorkflowEvent e)
@@ -1,8 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@@ -13,6 +16,128 @@ using Microsoft.Agents.AI.Workflows.Reflection;
namespace Microsoft.Agents.AI.Workflows;
internal sealed class DelayedExternalRequestContext : IExternalRequestContext
{
public DelayedExternalRequestContext(IExternalRequestContext? targetContext = null)
{
this._targetContext = targetContext;
}
private sealed class DelayRegisteredSink : IExternalRequestSink
{
internal IExternalRequestSink? TargetSink { get; set; }
public ValueTask PostAsync(ExternalRequest request) =>
this.TargetSink is null
? throw new InvalidOperationException("The external request sink has not been registered yet.")
: this.TargetSink.PostAsync(request);
}
private readonly Dictionary<string, (RequestPort Port, DelayRegisteredSink Sink)> _requestPorts = [];
private IExternalRequestContext? _targetContext;
public void ApplyPortRegistrations(IExternalRequestContext targetContext)
{
this._targetContext = targetContext;
foreach ((RequestPort requestPort, DelayRegisteredSink? sink) in this._requestPorts.Values)
{
sink?.TargetSink = targetContext.RegisterPort(requestPort);
}
}
public IExternalRequestSink RegisterPort(RequestPort port)
{
DelayRegisteredSink delaySink = new()
{
TargetSink = this._targetContext?.RegisterPort(port),
};
this._requestPorts.Add(port.Id, (port, delaySink));
return delaySink;
}
}
internal sealed class MessageTypeTranslator
{
private readonly Dictionary<TypeId, Type> _typeLookupMap = [];
private readonly Dictionary<Type, TypeId> _declaredTypeMap = [];
// The types that can always be sent; this is a very inelegant solution to the following problem:
// Even with code analysis it is impossible to statically know all of the types that get sent via SendMessage, because
// IWorkflowContext can always be sent out of the current assembly (to say nothing of Reflection). This means at some
// level we have to register all the types being sent somewhere. Since we have to do dynamic serialization/deserialization
// at runtime with dependency-defined types (which we do not statically know) we need to have these types at runtime.
// At the same time, we should not force users to declare types to interact with core system concepts like RequestInfo.
// So the solution for now is to register a set of known types, at the cost of duplicating this per Executor.
//
// - TODO: Create a static translation map, and keep a set of "allowed" TypeIds per Excutor.
private static IEnumerable<Type> KnownSentTypes =>
[
typeof(ExternalRequest),
typeof(ExternalResponse),
// TurnToken?
];
public MessageTypeTranslator(ISet<Type> types)
{
foreach (Type type in KnownSentTypes.Concat(types))
{
TypeId typeId = new(type);
if (this._typeLookupMap.ContainsKey(typeId))
{
continue;
}
this._typeLookupMap[typeId] = type;
this._declaredTypeMap[type] = typeId;
}
}
public TypeId? GetDeclaredType(Type messageType)
{
// If the user declares a base type, the user is expected to set up any serialization to be able to deal with
// the polymorphism transparently to the framework, or be expecting to deal with the appropriate truncation.
for (Type? candidateType = messageType; candidateType != null; candidateType = candidateType.BaseType)
{
if (this._declaredTypeMap.TryGetValue(candidateType, out TypeId? declaredTypeId))
{
if (candidateType != messageType)
{
// Add an entry for the derived type to speed up future lookups.
this._declaredTypeMap[messageType] = declaredTypeId;
}
return declaredTypeId;
}
}
return null;
}
public Type? MapTypeId(TypeId candidateTypeId) =>
this._typeLookupMap.TryGetValue(candidateTypeId, out Type? mappedType)
? mappedType
: null;
}
internal sealed class ExecutorProtocol(MessageRouter router, ISet<Type> sendTypes, ISet<Type> yieldTypes)
{
private readonly HashSet<TypeId> _yieldTypes = new(yieldTypes.Select(type => new TypeId(type)));
public MessageTypeTranslator SendTypeTranslator => field ??= new MessageTypeTranslator(sendTypes);
internal MessageRouter Router => router;
public bool CanHandle(Type type) => router.CanHandle(type);
public bool CanOutput(Type type) => this._yieldTypes.Contains(new(type));
public ProtocolDescriptor Describe() => new(this.Router.IncomingTypes, yieldTypes, sendTypes, this.Router.HasCatchAll);
}
/// <summary>
/// A component that processes messages in a <see cref="Workflow"/>.
/// </summary>
@@ -24,9 +149,6 @@ public abstract class Executor : IIdentified
/// </summary>
public string Id { get; }
private static readonly string s_namespace = typeof(Executor).Namespace!;
private static readonly ActivitySource s_activitySource = new(s_namespace);
// TODO: Add overloads for binding with a configuration/options object once the Configured<T> hierarchy goes away.
/// <summary>
@@ -51,6 +173,10 @@ public abstract class Executor : IIdentified
this.IsCrossRunShareable = declareCrossRunShareable;
}
private DelayedExternalRequestContext DelayedPortRegistrations { get; } = new();
internal ExecutorProtocol Protocol => field ??= this.ConfigureProtocol(new(this.DelayedPortRegistrations)).Build(this.Options);
internal bool IsCrossRunShareable { get; }
/// <summary>
@@ -58,28 +184,29 @@ public abstract class Executor : IIdentified
/// </summary>
protected ExecutorOptions Options { get; }
/// <summary>
/// Override this method to register handlers for the executor.
/// </summary>
protected abstract RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder);
//private bool _configuringProtocol;
internal void Configure(IExternalRequestContext externalRequestContext)
/// <summary>
/// Configures the protocol by setting up routes and declaring the message types used for sending and yielding
/// output.
/// </summary>
/// <remarks>This method serves as the primary entry point for protocol configuration. It integrates route
/// setup and message type declarations. For backward compatibility, it is currently invoked from the
/// RouteBuilder.</remarks>
/// <returns>An instance of <see cref="ExecutorProtocol"/> that represents the fully configured protocol.</returns>
protected abstract ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder);
internal void AttachRequestContext(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()
// >>> will throw InvalidOperationException if AttachRequestContext() is not invoked when using PortHandlers
// .AttachRequestContext()
// >>> 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();
this.DelayedPortRegistrations.ApplyPortRegistrations(externalRequestContext);
_ = this.Protocol; // Force protocol to be built if not already done.
}
/// <summary>
/// Perform any asynchronous initialization required by the executor. This method is called once per executor instance,
@@ -91,42 +218,7 @@ public abstract class Executor : IIdentified
protected internal virtual ValueTask InitializeAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
=> default;
/// <summary>
/// Override this method to declare the types of messages this executor can send.
/// </summary>
/// <returns></returns>
protected virtual ISet<Type> ConfigureSentTypes() => new HashSet<Type>([typeof(object)]);
/// <summary>
/// Override this method to declare the types of messages this executor can yield as workflow outputs.
/// </summary>
/// <returns></returns>
protected virtual ISet<Type> ConfigureYieldTypes()
{
if (this.Options.AutoYieldOutputHandlerResultObject)
{
return this.Router.DefaultOutputTypes;
}
return new HashSet<Type>();
}
internal MessageRouter Router
{
get
{
if (field is null)
{
field = this.CreateRouter();
}
return field;
}
private set
{
field = value;
}
}
internal MessageRouter Router => this.Protocol.Router;
/// <summary>
/// Process an incoming message using the registered handlers.
@@ -140,13 +232,13 @@ public abstract class Executor : IIdentified
/// <returns>A ValueTask representing the asynchronous operation, wrapping the output from the executor.</returns>
/// <exception cref="NotSupportedException">No handler found for the message type.</exception>
/// <exception cref="TargetInvocationException">An exception is generated while handling the message.</exception>
public async ValueTask<object?> ExecuteAsync(object message, TypeId messageType, IWorkflowContext context, CancellationToken cancellationToken = default)
public ValueTask<object?> ExecuteCoreAsync(object message, TypeId messageType, IWorkflowContext context, CancellationToken cancellationToken = default)
=> this.ExecuteCoreAsync(message, messageType, context, WorkflowTelemetryContext.Disabled, cancellationToken);
internal async ValueTask<object?> ExecuteCoreAsync(object message, TypeId messageType, IWorkflowContext context, WorkflowTelemetryContext telemetryContext, CancellationToken cancellationToken = default)
{
using var activity = s_activitySource.StartActivity(ActivityNames.ExecutorProcess, ActivityKind.Internal);
activity?.SetTag(Tags.ExecutorId, this.Id)
.SetTag(Tags.ExecutorType, this.GetType().FullName)
.SetTag(Tags.MessageType, messageType.TypeName)
.CreateSourceLinks(context.TraceContext);
using var activity = telemetryContext.StartExecutorProcessActivity(this.Id, this.GetType().FullName, messageType.TypeName, message);
activity?.CreateSourceLinks(context.TraceContext);
await context.AddEventAsync(new ExecutorInvokedEvent(this.Id, message), cancellationToken).ConfigureAwait(false);
@@ -181,6 +273,11 @@ public abstract class Executor : IIdentified
return null; // Void result.
}
// Output is not available if executor does not return anything, in which case
// messages sent in the handlers of this executor will be set in the message
// send activities.
telemetryContext.SetExecutorOutput(activity, result.Result);
// If we had a real return type, raise it as a SendMessage; TODO: Should we have a way to disable this behaviour?
if (result.Result is not null && this.Options.AutoSendMessageHandlerResultObject)
{
@@ -220,41 +317,22 @@ public abstract class Executor : IIdentified
/// <summary>
/// A set of <see cref="Type"/>s, representing the messages this executor can produce as output.
/// </summary>
public ISet<Type> OutputTypes { get; } = new HashSet<Type>([typeof(object)]);
public ISet<Type> OutputTypes => field ??= new HashSet<Type>(this.Protocol.Describe().Yields);
/// <summary>
/// Describes the protocol for communication with this <see cref="Executor"/>.
/// </summary>
/// <returns></returns>
public ProtocolDescriptor DescribeProtocol()
{
// TODO: Once burden of annotating yield/output messages becomes easier for the non-Auto case,
// we should (1) start checking for validity on output/send side, and (2) add the Yield/Send
// types to the ProtocolDescriptor.
return new(this.InputTypes, this.Router.HasCatchAll);
}
public ProtocolDescriptor DescribeProtocol() => this.Protocol.Describe();
/// <summary>
/// Checks if the executor can handle a specific message type.
/// </summary>
/// <param name="messageType"></param>
/// <returns></returns>
public bool CanHandle(Type messageType) => this.Router.CanHandle(messageType);
public bool CanHandle(Type messageType) => this.Protocol.CanHandle(messageType);
internal bool CanHandle(TypeId messageType) => this.Router.CanHandle(messageType);
internal bool CanOutput(Type messageType)
{
foreach (Type type in this.OutputTypes)
{
if (type.IsAssignableFrom(messageType))
{
return true;
}
}
return false;
}
internal bool CanOutput(Type messageType) => this.Protocol.CanOutput(messageType);
}
/// <summary>
@@ -268,8 +346,14 @@ public abstract class Executor<TInput>(string id, ExecutorOptions? options = nul
: Executor(id, options, declareCrossRunShareable), IMessageHandler<TInput>
{
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TInput>(this.HandleAsync);
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
Func<TInput, IWorkflowContext, CancellationToken, ValueTask> handlerDelegate = this.HandleAsync;
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate))
.AddMethodAttributeTypes(handlerDelegate.Method)
.AddClassAttributeTypes(this.GetType());
}
/// <inheritdoc/>
public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default);
@@ -288,8 +372,14 @@ public abstract class Executor<TInput, TOutput>(string id, ExecutorOptions? opti
IMessageHandler<TInput, TOutput>
{
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TInput, TOutput>(this.HandleAsync);
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> handlerDelegate = this.HandleAsync;
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate))
.AddMethodAttributeTypes(handlerDelegate.Method)
.AddClassAttributeTypes(this.GetType());
}
/// <inheritdoc/>
public abstract ValueTask<TOutput> HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default);
@@ -58,9 +58,9 @@ public abstract record class ExecutorBinding(string Id, Func<string, ValueTask<E
return executor;
}
internal async ValueTask<Executor> CreateInstanceAsync(string runId)
internal async ValueTask<Executor> CreateInstanceAsync(string sessionId)
=> !this.IsPlaceholder
? this.CheckId(await this.FactoryAsync(runId).ConfigureAwait(false))
? this.CheckId(await this.FactoryAsync(sessionId).ConfigureAwait(false))
: throw new InvalidOperationException(
$"Cannot create executor with ID '{this.Id}': Binding ({this.GetType().Name}) is a placeholder.");
@@ -40,7 +40,7 @@ public static class ExecutorBindingExtensions
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorBinding BindExecutor<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync)
where TExecutor : Executor
=> BindExecutor<TExecutor, ExecutorOptions>((config, runId) => factoryAsync(config.Id, runId), id: typeof(TExecutor).Name, options: null);
=> BindExecutor<TExecutor, ExecutorOptions>((config, sessionId) => factoryAsync(config.Id, sessionId), id: typeof(TExecutor).Name, options: null);
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, using the
@@ -77,7 +77,7 @@ public static class ExecutorBindingExtensions
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorBinding BindExecutor<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync, string id)
where TExecutor : Executor
=> BindExecutor<TExecutor, ExecutorOptions>((_, runId) => factoryAsync(id, runId), id, options: null);
=> BindExecutor<TExecutor, ExecutorOptions>((_, sessionId) => factoryAsync(id, sessionId), id, options: null);
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
@@ -15,26 +15,28 @@ namespace Microsoft.Agents.AI.Workflows;
/// <param name="Data">The data contained in the request.</param>
public record ExternalRequest(RequestPortInfo PortInfo, string RequestId, PortableValue Data)
{
/// <summary>
/// Attempts to retrieve the underlying data as the specified type.
/// </summary>
/// <typeparam name="TValue">The type to which the data should be cast or converted.</typeparam>
/// <returns>The data cast to the specified type, or null if the data cannot be cast to the specified type.</returns>
public TValue? DataAs<TValue>() => this.Data.As<TValue>();
/// <summary>
/// Determines whether the underlying data is of the specified type.
/// </summary>
/// <typeparam name="TValue">The type to compare with the underlying data.</typeparam>
/// <returns>true if the underlying data is of type TValue; otherwise, false.</returns>
public bool DataIs<TValue>() => this.Data.Is<TValue>();
public bool IsDataOfType<TValue>() => this.Data.Is<TValue>();
/// <summary>
/// Determines whether the underlying data is of the specified type and outputs the value if it is.
/// </summary>
/// <typeparam name="TValue">The type to compare with the underlying data.</typeparam>
/// <returns>true if the underlying data is of type TValue; otherwise, false.</returns>
public bool DataIs<TValue>([NotNullWhen(true)] out TValue? value) => this.Data.Is(out value);
public bool TryGetDataAs<TValue>([NotNullWhen(true)] out TValue? value) => this.Data.Is(out value);
/// <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 TryGetDataAs(Type targetType, [NotNullWhen(true)] out object? value) => this.Data.IsType(targetType, out value);
/// <summary>
/// Creates a new <see cref="ExternalRequest"/> for the specified input port and data payload.
@@ -14,19 +14,12 @@ namespace Microsoft.Agents.AI.Workflows;
/// <param name="Data">The data contained in the response.</param>
public record ExternalResponse(RequestPortInfo PortInfo, string RequestId, PortableValue Data)
{
/// <summary>
/// Attempts to retrieve the underlying data as the specified type.
/// </summary>
/// <typeparam name="TValue">The type to which the data should be cast or converted.</typeparam>
/// <returns>The data cast to the specified type, or null if the data cannot be cast to the specified type.</returns>
public TValue? DataAs<TValue>() => this.Data.As<TValue>();
/// <summary>
/// Determines whether the underlying data is of the specified type.
/// </summary>
/// <typeparam name="TValue">The type to compare with the underlying data.</typeparam>
/// <returns>true if the underlying data is of type TValue; otherwise, false.</returns>
public bool DataIs<TValue>() => this.Data.Is<TValue>();
public bool IsDataOfType<TValue>() => this.Data.Is<TValue>();
/// <summary>
/// Determines whether the underlying data can be retrieved as the specified type.
@@ -35,14 +28,7 @@ public record ExternalResponse(RequestPortInfo PortInfo, string RequestId, Porta
/// <param name="value">When this method returns, contains the value of type <typeparamref name="TValue"/> if the data is
/// available and compatible.</param>
/// <returns>true if the data is present and can be cast to <typeparamref name="TValue"/>; otherwise, false.</returns>
public bool DataIs<TValue>([NotNullWhen(true)] out TValue? value) => this.Data.Is(out value);
/// <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>
/// <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);
public bool TryGetDataAs<TValue>([NotNullWhen(true)] out TValue? value) => this.Data.Is(out value);
/// <summary>
/// Attempts to retrieve the underlying data as the specified type.
@@ -51,5 +37,5 @@ public record ExternalResponse(RequestPortInfo PortInfo, string RequestId, Porta
/// <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);
public bool TryGetDataAs(Type targetType, [NotNullWhen(true)] out object? value) => this.Data.IsType(targetType, out value);
}
@@ -1,6 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@@ -13,14 +16,28 @@ namespace Microsoft.Agents.AI.Workflows;
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="handlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="sentMessageTypes">Message types sent by the handler. Defaults to empty, and will filter out non-matching messages.</param>
/// <param name="outputTypes">Message types yielded as output by the handler. Defaults to empty.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public class FunctionExecutor<TInput>(string id,
Func<TInput, IWorkflowContext, CancellationToken, ValueTask> handlerAsync,
ExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : Executor<TInput>(id, options, declareCrossRunShareable)
{
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask> WrapAction(Action<TInput, IWorkflowContext, CancellationToken> handlerSync)
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask> WrapAction(Action<TInput, IWorkflowContext, CancellationToken> handlerSync, out IEnumerable<Type> sentTypes, out IEnumerable<Type> yieldedTypes)
{
if (handlerSync.Method != null)
{
MethodInfo method = handlerSync.Method;
(sentTypes, yieldedTypes) = method.GetAttributeTypes();
}
else
{
sentTypes = yieldedTypes = [];
}
return RunActionAsync;
ValueTask RunActionAsync(TInput input, IWorkflowContext workflowContext, CancellationToken cancellationToken)
@@ -30,6 +47,15 @@ public class FunctionExecutor<TInput>(string id,
}
}
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
base.ConfigureProtocol(protocolBuilder)
// We have to register the delegate handlers here because the base class gets the RunActionAsync local function in
// WrapAction, which cannot have the right annotations.
.AddDelegateAttributeTypes(handlerAsync)
.SendsMessageTypes(sentMessageTypes ?? [])
.YieldsOutputTypes(outputTypes ?? []);
/// <inheritdoc/>
public override ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken) => handlerAsync(message, context, cancellationToken);
@@ -39,8 +65,15 @@ public class FunctionExecutor<TInput>(string id,
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="handlerSync">A synchronous function to execute for each input message and workflow context.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="sentMessageTypes">Message types sent by the handler. Defaults to empty, and will filter out non-matching messages.</param>
/// <param name="outputTypes">Message types yielded as output by the handler. Defaults to empty.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public FunctionExecutor(string id, Action<TInput, IWorkflowContext, CancellationToken> handlerSync, ExecutorOptions? options = null, bool declareCrossRunShareable = false) : this(id, WrapAction(handlerSync), options, declareCrossRunShareable)
public FunctionExecutor(string id,
Action<TInput, IWorkflowContext, CancellationToken> handlerSync,
ExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : this(id, WrapAction(handlerSync, out var attributeSentTypes, out var attributeYieldTypes), options, attributeSentTypes.Concat(sentMessageTypes ?? []), attributeYieldTypes.Concat(outputTypes ?? []), declareCrossRunShareable)
{
}
}
@@ -53,10 +86,14 @@ public class FunctionExecutor<TInput>(string id,
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="handlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="sentMessageTypes">Additional message types sent by the handler. Defaults to empty, and will filter out non-matching messages.</param>
/// <param name="outputTypes">Additional message types yielded as output by the handler. Defaults to empty.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public class FunctionExecutor<TInput, TOutput>(string id,
Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> handlerAsync,
ExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : Executor<TInput, TOutput>(id, options, declareCrossRunShareable)
{
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> WrapFunc(Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync)
@@ -70,6 +107,15 @@ public class FunctionExecutor<TInput, TOutput>(string id,
}
}
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
base.ConfigureProtocol(protocolBuilder)
// We have to register the delegate handlers here because the base class gets the RunFuncAsync local function in
// WrapFunc, which cannot have the right annotations.
.AddDelegateAttributeTypes(handlerAsync)
.SendsMessageTypes(sentMessageTypes ?? [])
.YieldsOutputTypes(outputTypes ?? []);
/// <inheritdoc/>
public override ValueTask<TOutput> HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken) => handlerAsync(message, context, cancellationToken);
@@ -79,8 +125,15 @@ public class FunctionExecutor<TInput, TOutput>(string id,
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="handlerSync">A synchronous function to execute for each input message and workflow context.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="sentMessageTypes">Additional message types sent by the handler. Defaults to empty, and will filter out non-matching messages.</param>
/// <param name="outputTypes">Additional message types yielded as output by the handler. Defaults to empty.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public FunctionExecutor(string id, Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync, ExecutorOptions? options = null, bool declareCrossRunShareable = false) : this(id, WrapFunc(handlerSync), options, declareCrossRunShareable)
public FunctionExecutor(string id,
Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync,
ExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : this(id, WrapFunc(handlerSync), options, sentMessageTypes, outputTypes, declareCrossRunShareable)
{
}
}
@@ -16,6 +16,8 @@ public sealed class GroupChatWorkflowBuilder
{
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory;
private readonly HashSet<AIAgent> _participants = new(AIAgentIDEqualityComparer.Instance);
private string _name = string.Empty;
private string _description = string.Empty;
internal GroupChatWorkflowBuilder(Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) =>
this._managerFactory = managerFactory;
@@ -42,6 +44,28 @@ public sealed class GroupChatWorkflowBuilder
return this;
}
/// <summary>
/// Sets the human-readable name for the workflow.
/// </summary>
/// <param name="name">The name of the workflow.</param>
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
public GroupChatWorkflowBuilder WithName(string name)
{
this._name = name;
return this;
}
/// <summary>
/// Sets the description for the workflow.
/// </summary>
/// <param name="description">The description of what the workflow does.</param>
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
public GroupChatWorkflowBuilder WithDescription(string description)
{
this._description = description;
return this;
}
/// <summary>
/// Builds a <see cref="Workflow"/> composed of agents that operate via group chat, with the next
/// agent to process messages selected by the group chat manager.
@@ -60,11 +84,21 @@ public sealed class GroupChatWorkflowBuilder
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));
(id, sessionId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory));
ExecutorBinding host = groupChatHostFactory.BindExecutor(nameof(GroupChatHost));
WorkflowBuilder builder = new(host);
if (!string.IsNullOrEmpty(this._name))
{
builder = builder.WithName(this._name);
}
if (!string.IsNullOrEmpty(this._description))
{
builder = builder.WithDescription(this._description);
}
foreach (var participant in agentMap.Values)
{
builder
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Specifies the behavior for filtering <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents from
/// <see cref="ChatMessage"/>s flowing through a handoff workflow. This can be used to prevent agents from seeing external
/// tool calls.
/// </summary>
public enum HandoffToolCallFilteringBehavior
{
/// <summary>
/// Do not filter <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents.
/// </summary>
None,
/// <summary>
/// Filter only handoff-related <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents.
/// </summary>
HandoffOnly,
/// <summary>
/// Filter all <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents.
/// </summary>
All
}
@@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
@@ -16,6 +17,7 @@ public sealed class HandoffsWorkflowBuilder
private readonly AIAgent _initialAgent;
private readonly Dictionary<AIAgent, HashSet<HandoffTarget>> _targets = [];
private readonly HashSet<AIAgent> _allAgents = new(AIAgentIDEqualityComparer.Instance);
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
@@ -34,14 +36,38 @@ public sealed class HandoffsWorkflowBuilder
/// By default, simple instructions are included. This may be set to <see langword="null"/> to avoid including
/// any additional instructions, or may be customized to provide more specific guidance.
/// </remarks>
public string? HandoffInstructions { get; set; } =
$"""
public string? HandoffInstructions { get; private set; } = DefaultHandoffInstructions;
private const string DefaultHandoffInstructions =
$"""
You are one agent in a multi-agent system. You can hand off the conversation to another agent if appropriate. Handoffs are achieved
by calling a handoff function, named in the form `{FunctionPrefix}<agent_id>`; the description of the function provides details on the
target agent of that handoff. Handoffs between agents are handled seamlessly in the background; never mention or narrate these handoffs
in your conversation with the user.
""";
/// <summary>
/// Sets additional instructions to provide to an agent that has handoffs about how and when to
/// perform them.
/// </summary>
/// <param name="instructions">The instructions to provide, or <see langword="null"/> to restore the default instructions.</param>
public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
{
this.HandoffInstructions = instructions ?? DefaultHandoffInstructions;
return this;
}
/// <summary>
/// Sets the behavior for filtering <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents from
/// <see cref="ChatMessage"/>s flowing through the handoff workflow. Defaults to <see cref="HandoffToolCallFilteringBehavior.HandoffOnly"/>.
/// </summary>
/// <param name="behavior">The filtering behavior to apply.</param>
public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
{
this._toolCallFilteringBehavior = behavior;
return this;
}
/// <summary>
/// Adds handoff relationships from a source agent to one or more target agents.
/// </summary>
@@ -149,8 +175,10 @@ public sealed class HandoffsWorkflowBuilder
HandoffsEndExecutor end = new();
WorkflowBuilder builder = new(start);
HandoffAgentExecutorOptions options = new(this.HandoffInstructions, this._toolCallFilteringBehavior);
// Create an AgentExecutor for each again.
Dictionary<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, this.HandoffInstructions));
Dictionary<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options));
// Connect the start executor to the initial agent.
builder.AddEdge(start, executors[this._initialAgent.Id]);
@@ -11,16 +11,21 @@ namespace Microsoft.Agents.AI.Workflows;
/// </summary>
public interface IWorkflowExecutionEnvironment
{
/// <summary>
/// Specifies whether Checkpointing is configured for this environment.
/// </summary>
bool IsCheckpointingEnabled { get; }
/// <summary>
/// Initiates a streaming run of the specified workflow without sending any initial input. Note that the starting
/// <see cref="Executor"/> will not be invoked until an input message is received.
/// </summary>
/// <param name="workflow">The workflow to execute. Cannot be null.</param>
/// <param name="runId">An optional identifier for the run. If null, a new run identifier will be generated.</param>
/// <param name="sessionId">An optional identifier for the session. If null, a new identifier will be generated.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the streaming operation.</param>
/// <returns>A ValueTask that represents the asynchronous operation. The result contains a StreamingRun object for accessing
/// the streamed workflow output.</returns>
ValueTask<StreamingRun> OpenStreamAsync(Workflow workflow, string? runId = null, CancellationToken cancellationToken = default);
ValueTask<StreamingRun> OpenStreamingAsync(Workflow workflow, string? sessionId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Initiates an asynchronous streaming execution using the specified input.
@@ -31,41 +36,11 @@ public interface IWorkflowExecutionEnvironment
/// <typeparam name="TInput">A type of input accepted by the workflow. Must be non-nullable.</typeparam>
/// <param name="workflow">The workflow to be executed. Must not be <c>null</c>.</param>
/// <param name="input">The input message to be processed as part of the streaming run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="sessionId">An optional unique identifier for the session. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{StreamingRun}"/> that represents the asynchronous operation. The result contains a <see
/// cref="StreamingRun"/> for managing and interacting with the streaming run.</returns>
ValueTask<StreamingRun> StreamAsync<TInput>(Workflow workflow, TInput input, string? runId = null, CancellationToken cancellationToken = default) where TInput : notnull;
/// <summary>
/// Initiates an asynchronous streaming execution without sending any initial input, with checkpointing.
/// </summary>
/// <remarks>The returned <see cref="StreamingRun"/> provides methods to observe and control
/// the ongoing streaming execution. The operation will continue until the streaming execution is finished or
/// cancelled.</remarks>
/// <param name="workflow">The workflow to be executed. Must not be <c>null</c>.</param>
/// <param name="checkpointManager">The <see cref="CheckpointManager"/> to use with this run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{StreamingRun}"/> that represents the asynchronous operation. The result contains a <see
/// cref="StreamingRun"/> for managing and interacting with the streaming run.</returns>
ValueTask<Checkpointed<StreamingRun>> StreamAsync(Workflow workflow, CheckpointManager checkpointManager, string? runId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Initiates an asynchronous streaming execution using the specified input, with checkpointing.
/// </summary>
/// <remarks>The returned <see cref="StreamingRun"/> provides methods to observe and control
/// the ongoing streaming execution. The operation will continue until the streaming execution is finished or
/// cancelled.</remarks>
/// <typeparam name="TInput">The type of input accepted by the workflow. Must be non-nullable.</typeparam>
/// <param name="workflow">The workflow to be executed. Must not be <c>null</c>.</param>
/// <param name="input">The input message to be processed as part of the streaming run.</param>
/// <param name="checkpointManager">The <see cref="CheckpointManager"/> to use with this run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{StreamingRun}"/> that represents the asynchronous operation. The result contains a <see
/// cref="StreamingRun"/> for managing and interacting with the streaming run.</returns>
ValueTask<Checkpointed<StreamingRun>> StreamAsync<TInput>(Workflow workflow, TInput input, CheckpointManager checkpointManager, string? runId = null, CancellationToken cancellationToken = default) where TInput : notnull;
ValueTask<StreamingRun> RunStreamingAsync<TInput>(Workflow workflow, TInput input, string? sessionId = null, CancellationToken cancellationToken = default) where TInput : notnull;
/// <summary>
/// Resumes an asynchronous streaming execution for the specified input from a checkpoint.
@@ -74,10 +49,9 @@ public interface IWorkflowExecutionEnvironment
/// be terminated.</remarks>
/// <param name="workflow">The workflow to be executed. Must not be <c>null</c>.</param>
/// <param name="fromCheckpoint">The <see cref="CheckpointInfo"/> corresponding to the checkpoint from which to resume.</param>
/// <param name="checkpointManager">The <see cref="CheckpointManager"/> to use with this run.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="StreamingRun"/> that provides access to the results of the streaming run.</returns>
ValueTask<Checkpointed<StreamingRun>> ResumeStreamAsync(Workflow workflow, CheckpointInfo fromCheckpoint, CheckpointManager checkpointManager, CancellationToken cancellationToken = default);
ValueTask<StreamingRun> ResumeStreamingAsync(Workflow workflow, CheckpointInfo fromCheckpoint, CancellationToken cancellationToken = default);
/// <summary>
/// Initiates a non-streaming execution of the workflow with the specified input.
@@ -87,26 +61,11 @@ public interface IWorkflowExecutionEnvironment
/// <typeparam name="TInput">The type of input accepted by the workflow. Must be non-nullable.</typeparam>
/// <param name="workflow">The workflow to be executed. Must not be <c>null</c>.</param>
/// <param name="input">The input message to be processed as part of the run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="sessionId">An optional unique identifier for the session. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{Run}"/> that represents the asynchronous operation. The result contains a <see
/// cref="Run"/> for managing and interacting with the streaming run.</returns>
ValueTask<Run> RunAsync<TInput>(Workflow workflow, TInput input, string? runId = null, CancellationToken cancellationToken = default) where TInput : notnull;
/// <summary>
/// Initiates a non-streaming execution of the workflow with the specified input, with checkpointing.
/// </summary>
/// <remarks>The workflow will run until its first halt, and the returned <see cref="Run"/> will capture
/// all outgoing events. Use the <c>Run</c> instance to resume execution with responses to outgoing events.</remarks>
/// <typeparam name="TInput">The type of input accepted by the workflow. Must be non-nullable.</typeparam>
/// <param name="workflow">The workflow to be executed. Must not be <c>null</c>.</param>
/// <param name="input">The input message to be processed as part of the run.</param>
/// <param name="checkpointManager">The <see cref="CheckpointManager"/> to use with this run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{Run}"/> that represents the asynchronous operation. The result contains a <see
/// cref="Run"/> for managing and interacting with the streaming run.</returns>
ValueTask<Checkpointed<Run>> RunAsync<TInput>(Workflow workflow, TInput input, CheckpointManager checkpointManager, string? runId = null, CancellationToken cancellationToken = default) where TInput : notnull;
ValueTask<Run> RunAsync<TInput>(Workflow workflow, TInput input, string? sessionId = null, CancellationToken cancellationToken = default) where TInput : notnull;
/// <summary>
/// Resumes a non-streaming execution of the workflow from a checkpoint.
@@ -115,9 +74,8 @@ public interface IWorkflowExecutionEnvironment
/// all outgoing events. Use the <c>Run</c> instance to resume execution with responses to outgoing events.</remarks>
/// <param name="workflow">The workflow to be executed. Must not be <c>null</c>.</param>
/// <param name="fromCheckpoint">The <see cref="CheckpointInfo"/> corresponding to the checkpoint from which to resume.</param>
/// <param name="checkpointManager">The <see cref="CheckpointManager"/> to use with this run.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{Run}"/> that represents the asynchronous operation. The result contains a <see
/// cref="Run"/> for managing and interacting with the streaming run.</returns>
ValueTask<Checkpointed<Run>> ResumeAsync(Workflow workflow, CheckpointInfo fromCheckpoint, CheckpointManager checkpointManager, CancellationToken cancellationToken = default);
ValueTask<Run> ResumeAsync(Workflow workflow, CheckpointInfo fromCheckpoint, CancellationToken cancellationToken = default);
}
@@ -2,9 +2,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows.InProc;
@@ -15,103 +15,102 @@ namespace Microsoft.Agents.AI.Workflows.InProc;
/// </summary>
public sealed class InProcessExecutionEnvironment : IWorkflowExecutionEnvironment
{
internal InProcessExecutionEnvironment(ExecutionMode mode, bool enableConcurrentRuns = false)
internal InProcessExecutionEnvironment(ExecutionMode mode, bool enableConcurrentRuns = false, CheckpointManager? checkpointManager = null)
{
this.ExecutionMode = mode;
this.EnableConcurrentRuns = enableConcurrentRuns;
this.CheckpointManager = checkpointManager;
}
/// <summary>
/// Configure a new execution environment, inheriting configuration for the current one with the specified <see cref="Workflows.CheckpointManager"/>
/// for use in checkpointing.
/// </summary>
/// <param name="checkpointManager">The CheckpointManager to use for checkpointing.</param>
/// <returns>
/// A new InProcess <see cref="IWorkflowExecutionEnvironment"/> configured for checkpointing, inheriting configuration from the current
/// environment.
/// </returns>
public InProcessExecutionEnvironment WithCheckpointing(CheckpointManager? checkpointManager)
{
return new(this.ExecutionMode, this.EnableConcurrentRuns, checkpointManager);
}
internal ExecutionMode ExecutionMode { get; }
internal bool EnableConcurrentRuns { get; }
internal CheckpointManager? CheckpointManager { get; }
internal ValueTask<AsyncRunHandle> BeginRunAsync(Workflow workflow, ICheckpointManager? checkpointManager, string? runId, IEnumerable<Type> knownValidInputTypes, CancellationToken cancellationToken)
/// <inheritdoc/>
public bool IsCheckpointingEnabled => this.CheckpointManager != null;
internal ValueTask<AsyncRunHandle> BeginRunAsync(Workflow workflow, string? sessionId, IEnumerable<Type> knownValidInputTypes, CancellationToken cancellationToken)
{
InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, checkpointManager, runId, this.EnableConcurrentRuns, knownValidInputTypes);
InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, this.CheckpointManager, sessionId, this.EnableConcurrentRuns, knownValidInputTypes);
return runner.BeginStreamAsync(this.ExecutionMode, cancellationToken);
}
internal ValueTask<AsyncRunHandle> ResumeRunAsync(Workflow workflow, ICheckpointManager? checkpointManager, CheckpointInfo fromCheckpoint, IEnumerable<Type> knownValidInputTypes, CancellationToken cancellationToken)
internal ValueTask<AsyncRunHandle> ResumeRunAsync(Workflow workflow, CheckpointInfo fromCheckpoint, IEnumerable<Type> knownValidInputTypes, CancellationToken cancellationToken)
{
InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, checkpointManager, fromCheckpoint.RunId, this.EnableConcurrentRuns, knownValidInputTypes);
InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, this.CheckpointManager, fromCheckpoint.SessionId, this.EnableConcurrentRuns, knownValidInputTypes);
return runner.ResumeStreamAsync(this.ExecutionMode, fromCheckpoint, cancellationToken);
}
/// <inheritdoc/>
public async ValueTask<StreamingRun> OpenStreamAsync(
public async ValueTask<StreamingRun> OpenStreamingAsync(
Workflow workflow,
string? runId = null,
string? sessionId = null,
CancellationToken cancellationToken = default)
{
AsyncRunHandle runHandle = await this.BeginRunAsync(workflow, checkpointManager: null, runId: runId, [], cancellationToken)
AsyncRunHandle runHandle = await this.BeginRunAsync(workflow, sessionId, [], cancellationToken)
.ConfigureAwait(false);
return new(runHandle);
}
/// <inheritdoc/>
public async ValueTask<StreamingRun> StreamAsync<TInput>(
public async ValueTask<StreamingRun> RunStreamingAsync<TInput>(
Workflow workflow,
TInput input,
string? runId = null,
string? sessionId = null,
CancellationToken cancellationToken = default) where TInput : notnull
{
AsyncRunHandle runHandle = await this.BeginRunAsync(workflow, checkpointManager: null, runId: runId, [], cancellationToken)
AsyncRunHandle runHandle = await this.BeginRunAsync(workflow, sessionId, [], cancellationToken)
.ConfigureAwait(false);
return await runHandle.EnqueueAndStreamAsync(input, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask<Checkpointed<StreamingRun>> StreamAsync(
Workflow workflow,
CheckpointManager checkpointManager,
string? runId = null,
CancellationToken cancellationToken = default)
[MemberNotNull(nameof(CheckpointManager))]
private void VerifyCheckpointingConfigured()
{
AsyncRunHandle runHandle = await this.BeginRunAsync(workflow, checkpointManager, runId: runId, [], cancellationToken)
.ConfigureAwait(false);
return await runHandle.WithCheckpointingAsync<StreamingRun>(() => new(new StreamingRun(runHandle)))
.ConfigureAwait(false);
if (this.CheckpointManager == null)
{
throw new InvalidOperationException("Checkpointing is not configured for this execution environment. Please use the InProcessExecutionEnvironment.WithCheckpointing method to attach a CheckpointManager.");
}
}
/// <inheritdoc/>
public async ValueTask<Checkpointed<StreamingRun>> StreamAsync<TInput>(
Workflow workflow,
TInput input,
CheckpointManager checkpointManager,
string? runId = null,
CancellationToken cancellationToken = default) where TInput : notnull
{
AsyncRunHandle runHandle = await this.BeginRunAsync(workflow, checkpointManager, runId: runId, [], cancellationToken)
.ConfigureAwait(false);
return await runHandle.WithCheckpointingAsync(() => runHandle.EnqueueAndStreamAsync(input, cancellationToken))
.ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask<Checkpointed<StreamingRun>> ResumeStreamAsync(
public async ValueTask<StreamingRun> ResumeStreamingAsync(
Workflow workflow,
CheckpointInfo fromCheckpoint,
CheckpointManager checkpointManager,
CancellationToken cancellationToken = default)
{
AsyncRunHandle runHandle = await this.ResumeRunAsync(workflow, checkpointManager, fromCheckpoint, [], cancellationToken)
this.VerifyCheckpointingConfigured();
AsyncRunHandle runHandle = await this.ResumeRunAsync(workflow, fromCheckpoint, [], cancellationToken)
.ConfigureAwait(false);
return await runHandle.WithCheckpointingAsync<StreamingRun>(() => new(new StreamingRun(runHandle)))
.ConfigureAwait(false);
return new(runHandle);
}
private async ValueTask<AsyncRunHandle> BeginRunHandlingChatProtocolAsync<TInput>(Workflow workflow,
TInput input,
CheckpointManager? checkpointManager,
string? runId = null,
string? sessionId = null,
CancellationToken cancellationToken = default)
{
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false);
AsyncRunHandle runHandle = await this.BeginRunAsync(workflow, checkpointManager, runId, descriptor.Accepts, cancellationToken)
AsyncRunHandle runHandle = await this.BeginRunAsync(workflow, sessionId, descriptor.Accepts, cancellationToken)
.ConfigureAwait(false);
await runHandle.EnqueueMessageAsync(input, cancellationToken).ConfigureAwait(false);
@@ -128,14 +127,13 @@ public sealed class InProcessExecutionEnvironment : IWorkflowExecutionEnvironmen
public async ValueTask<Run> RunAsync<TInput>(
Workflow workflow,
TInput input,
string? runId = null,
string? sessionId = null,
CancellationToken cancellationToken = default) where TInput : notnull
{
AsyncRunHandle runHandle = await this.BeginRunHandlingChatProtocolAsync(
workflow,
input,
checkpointManager: null,
runId,
sessionId,
cancellationToken)
.ConfigureAwait(false);
@@ -145,38 +143,16 @@ public sealed class InProcessExecutionEnvironment : IWorkflowExecutionEnvironmen
}
/// <inheritdoc/>
public async ValueTask<Checkpointed<Run>> RunAsync<TInput>(
Workflow workflow,
TInput input,
CheckpointManager checkpointManager,
string? runId = null,
CancellationToken cancellationToken = default) where TInput : notnull
{
AsyncRunHandle runHandle = await this.BeginRunHandlingChatProtocolAsync(
workflow,
input,
checkpointManager,
runId,
cancellationToken)
.ConfigureAwait(false);
Run run = new(runHandle);
await run.RunToNextHaltAsync(cancellationToken).ConfigureAwait(false);
return await runHandle.WithCheckpointingAsync(() => new ValueTask<Run>(run))
.ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask<Checkpointed<Run>> ResumeAsync(
public async ValueTask<Run> ResumeAsync(
Workflow workflow,
CheckpointInfo fromCheckpoint,
CheckpointManager checkpointManager,
CancellationToken cancellationToken = default)
{
AsyncRunHandle runHandle = await this.ResumeRunAsync(workflow, checkpointManager, fromCheckpoint, [], cancellationToken)
this.VerifyCheckpointingConfigured();
AsyncRunHandle runHandle = await this.ResumeRunAsync(workflow, fromCheckpoint, [], cancellationToken)
.ConfigureAwait(false);
return await runHandle.WithCheckpointingAsync<Run>(() => new(new Run(runHandle)))
.ConfigureAwait(false);
return new(runHandle);
}
}
@@ -9,6 +9,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Agents.AI.Workflows.Observability;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.InProc;
@@ -21,27 +22,27 @@ namespace Microsoft.Agents.AI.Workflows.InProc;
/// scenarios where workflow execution does not require executor distribution. </para></remarks>
internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
{
public static InProcessRunner CreateTopLevelRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, bool enableConcurrentRuns = false, IEnumerable<Type>? knownValidInputTypes = null)
public static InProcessRunner CreateTopLevelRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? sessionId = null, bool enableConcurrentRuns = false, IEnumerable<Type>? knownValidInputTypes = null)
{
return new InProcessRunner(workflow,
checkpointManager,
runId,
sessionId,
enableConcurrentRuns: enableConcurrentRuns,
knownValidInputTypes: knownValidInputTypes);
}
public static InProcessRunner CreateSubworkflowRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, object? existingOwnerSignoff = null, bool enableConcurrentRuns = false, IEnumerable<Type>? knownValidInputTypes = null)
public static InProcessRunner CreateSubworkflowRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? sessionId = null, object? existingOwnerSignoff = null, bool enableConcurrentRuns = false, IEnumerable<Type>? knownValidInputTypes = null)
{
return new InProcessRunner(workflow,
checkpointManager,
runId,
sessionId,
existingOwnerSignoff: existingOwnerSignoff,
enableConcurrentRuns: enableConcurrentRuns,
knownValidInputTypes: knownValidInputTypes,
subworkflow: true);
}
private InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, object? existingOwnerSignoff = null, bool subworkflow = false, bool enableConcurrentRuns = false, IEnumerable<Type>? knownValidInputTypes = null)
private InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? sessionId = null, object? existingOwnerSignoff = null, bool subworkflow = false, bool enableConcurrentRuns = false, IEnumerable<Type>? knownValidInputTypes = null)
{
if (enableConcurrentRuns && !workflow.AllowConcurrent)
{
@@ -49,11 +50,11 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
$"not supporting concurrent: {string.Join(", ", workflow.NonConcurrentExecutorIds)}");
}
this.RunId = runId ?? Guid.NewGuid().ToString("N");
this.SessionId = sessionId ?? Guid.NewGuid().ToString("N");
this.StartExecutorId = workflow.StartExecutorId;
this.Workflow = Throw.IfNull(workflow);
this.RunContext = new InProcessRunnerContext(workflow, this.RunId, withCheckpointing: checkpointManager != null, this.OutgoingEvents, this.StepTracer, existingOwnerSignoff, subworkflow, enableConcurrentRuns);
this.RunContext = new InProcessRunnerContext(workflow, this.SessionId, checkpointingEnabled: checkpointManager != null, this.OutgoingEvents, this.StepTracer, existingOwnerSignoff, subworkflow, enableConcurrentRuns);
this.CheckpointManager = checkpointManager;
this._knownValidInputTypes = knownValidInputTypes != null
@@ -64,12 +65,15 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
this.EdgeMap = new EdgeMap(this.RunContext, this.Workflow.Edges, this.Workflow.Ports.Values, this.Workflow.StartExecutorId, this.StepTracer);
}
/// <inheritdoc cref="ISuperStepRunner.RunId"/>
public string RunId { get; }
/// <inheritdoc cref="ISuperStepRunner.SessionId"/>
public string SessionId { get; }
/// <inheritdoc cref="ISuperStepRunner.StartExecutorId"/>
public string StartExecutorId { get; }
/// <inheritdoc cref="ISuperStepRunner.TelemetryContext"/>
public WorkflowTelemetryContext TelemetryContext => this.Workflow.TelemetryContext;
private readonly HashSet<Type> _knownValidInputTypes;
public async ValueTask<bool> IsValidInputTypeAsync(Type messageType, CancellationToken cancellationToken = default)
{
@@ -157,6 +161,8 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests;
bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions;
public bool IsCheckpointingEnabled => this.RunContext.IsCheckpointingEnabled;
public IReadOnlyList<CheckpointInfo> Checkpoints => this._checkpoints;
async ValueTask<bool> ISuperStepRunner.RunSuperStepAsync(CancellationToken cancellationToken)
@@ -197,13 +203,43 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
this.StepTracer.TraceActivated(receiverId);
while (envelopes.TryDequeue(out var envelope))
{
await executor.ExecuteAsync(
envelope.Message,
envelope.MessageType,
(object message, TypeId messageType) = await TranslateMessageAsync(envelope).ConfigureAwait(false);
await executor.ExecuteCoreAsync(
message,
messageType,
this.RunContext.BindWorkflowContext(receiverId, envelope.TraceContext),
this.TelemetryContext,
cancellationToken
).ConfigureAwait(false);
}
async ValueTask<(object, TypeId)> TranslateMessageAsync(MessageEnvelope envelope)
{
object? value = envelope.Message;
TypeId messageType = envelope.MessageType;
if (!envelope.IsExternal)
{
Executor source = await this.RunContext.EnsureExecutorAsync(envelope.SourceId, this.StepTracer, cancellationToken).ConfigureAwait(false);
Type? actualType = source.Protocol.SendTypeTranslator.MapTypeId(envelope.MessageType);
if (actualType == null)
{
// In principle, this should never happen, since we always use the SendTypeTranslator to generate the outgoing TypeId in the first place.
throw new InvalidOperationException($"Cannot translate message type ID '{envelope.MessageType}' from executor '{source.Id}'.");
}
messageType = new(actualType);
if (value is PortableValue portableValue &&
!portableValue.IsType(actualType, out value))
{
throw new InvalidOperationException($"Cannot interpret incoming message of type '{portableValue.TypeId}' as type '{actualType.FullName}'.");
}
}
return (value, messageType);
}
}
private async ValueTask RunSuperstepAsync(StepContext currentStep, CancellationToken cancellationToken)
@@ -240,6 +276,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
}
private WorkflowInfo? _workflowInfoCache;
private CheckpointInfo? _lastCheckpointInfo;
private readonly List<CheckpointInfo> _checkpoints = [];
internal async ValueTask CheckpointAsync(CancellationToken cancellationToken = default)
{
@@ -265,10 +302,10 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
RunnerStateData runnerData = await this.RunContext.ExportStateAsync().ConfigureAwait(false);
Dictionary<ScopeKey, PortableValue> stateData = await this.RunContext.StateManager.ExportStateAsync().ConfigureAwait(false);
Checkpoint checkpoint = new(this.StepTracer.StepNumber, this._workflowInfoCache, runnerData, stateData, edgeData);
CheckpointInfo checkpointInfo = await this.CheckpointManager.CommitCheckpointAsync(this.RunId, checkpoint).ConfigureAwait(false);
this.StepTracer.TraceCheckpointCreated(checkpointInfo);
this._checkpoints.Add(checkpointInfo);
Checkpoint checkpoint = new(this.StepTracer.StepNumber, this._workflowInfoCache, runnerData, stateData, edgeData, this._lastCheckpointInfo);
this._lastCheckpointInfo = await this.CheckpointManager.CommitCheckpointAsync(this.SessionId, checkpoint).ConfigureAwait(false);
this.StepTracer.TraceCheckpointCreated(this._lastCheckpointInfo);
this._checkpoints.Add(this._lastCheckpointInfo);
}
public async ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default)
@@ -280,7 +317,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
throw new InvalidOperationException("This run was not configured with a CheckpointManager, so it cannot restore checkpoints.");
}
Checkpoint checkpoint = await this.CheckpointManager.LookupCheckpointAsync(this.RunId, checkpointInfo)
Checkpoint checkpoint = await this.CheckpointManager.LookupCheckpointAsync(this.SessionId, checkpointInfo)
.ConfigureAwait(false);
// Validate the checkpoint is compatible with this workflow
@@ -290,6 +327,8 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
throw new InvalidDataException("The specified checkpoint is not compatible with the workflow associated with this runner.");
}
ValueTask restoreCheckpointIndexTask = UpdateCheckpointIndexAsync();
await this.RunContext.StateManager.ImportStateAsync(checkpoint).ConfigureAwait(false);
await this.RunContext.ImportStateAsync(checkpoint).ConfigureAwait(false);
@@ -297,9 +336,18 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
ValueTask republishRequestsTask = this.RunContext.RepublishUnservicedRequestsAsync(cancellationToken);
await this.EdgeMap.ImportStateAsync(checkpoint).ConfigureAwait(false);
await Task.WhenAll(executorNotifyTask, republishRequestsTask.AsTask()).ConfigureAwait(false);
await Task.WhenAll(executorNotifyTask,
republishRequestsTask.AsTask(),
restoreCheckpointIndexTask.AsTask()).ConfigureAwait(false);
this._lastCheckpointInfo = checkpointInfo;
this.StepTracer.Reload(this.StepTracer.StepNumber);
async ValueTask UpdateCheckpointIndexAsync()
{
this._checkpoints.Clear();
this._checkpoints.AddRange(await this.CheckpointManager!.RetrieveIndexAsync(this.SessionId).ConfigureAwait(false));
}
}
private bool CheckWorkflowMatch(Checkpoint checkpoint) =>
@@ -22,7 +22,7 @@ namespace Microsoft.Agents.AI.Workflows.InProc;
internal sealed class InProcessRunnerContext : IRunnerContext
{
private int _runEnded;
private readonly string _runId;
private readonly string _sessionId;
private readonly Workflow _workflow;
private readonly object? _previousOwnership;
private bool _ownsWorkflow;
@@ -40,8 +40,8 @@ internal sealed class InProcessRunnerContext : IRunnerContext
public InProcessRunnerContext(
Workflow workflow,
string runId,
bool withCheckpointing,
string sessionId,
bool checkpointingEnabled,
IEventSink outgoingEvents,
IStepTracer? stepTracer,
object? existingOwnershipSignoff = null,
@@ -61,15 +61,16 @@ internal sealed class InProcessRunnerContext : IRunnerContext
}
this._workflow = workflow;
this._runId = runId;
this._sessionId = sessionId;
this._edgeMap = new(this, this._workflow, stepTracer);
this._outputFilter = new(workflow);
this.WithCheckpointing = withCheckpointing;
this.IsCheckpointingEnabled = checkpointingEnabled;
this.ConcurrentRunsEnabled = enableConcurrentRuns;
this.OutgoingEvents = outgoingEvents;
}
public WorkflowTelemetryContext TelemetryContext => this._workflow.TelemetryContext;
public IExternalRequestSink RegisterPort(string executorId, RequestPort port)
{
@@ -93,8 +94,8 @@ internal sealed class InProcessRunnerContext : IRunnerContext
throw new InvalidOperationException($"Executor with ID '{executorId}' is not registered.");
}
Executor executor = await registration.CreateInstanceAsync(this._runId).ConfigureAwait(false);
executor.Configure(this.BindExternalRequestContext(executorId));
Executor executor = await registration.CreateInstanceAsync(this._sessionId).ConfigureAwait(false);
executor.AttachRequestContext(this.BindExternalRequestContext(executorId));
await executor.InitializeAsync(this.BindWorkflowContext(executorId), cancellationToken: cancellationToken)
.ConfigureAwait(false);
@@ -181,7 +182,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
while (this._queuedExternalDeliveries.TryDequeue(out var deliveryPrep))
{
// It's important we do not try to run these in parallel, because they make be modifying
// It's important we do not try to run these in parallel, because they may be modifying
// inner edge state, etc.
await deliveryPrep().ConfigureAwait(false);
}
@@ -195,12 +196,10 @@ internal sealed class InProcessRunnerContext : IRunnerContext
return this.OutgoingEvents.EnqueueAsync(workflowEvent);
}
private static readonly string s_namespace = typeof(IWorkflowContext).Namespace!;
private static readonly ActivitySource s_activitySource = new(s_namespace);
public async ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null, CancellationToken cancellationToken = default)
{
using Activity? activity = s_activitySource.StartActivity(ActivityNames.MessageSend, ActivityKind.Producer);
using Activity? activity = this._workflow.TelemetryContext.StartMessageSendActivity(sourceId, targetId, message);
// Create a carrier for trace context propagation
var traceContext = activity is null ? null : new Dictionary<string, string>();
if (traceContext is not null)
@@ -213,14 +212,23 @@ internal sealed class InProcessRunnerContext : IRunnerContext
}
this.CheckEnded();
MessageEnvelope envelope = new(message, sourceId, targetId: targetId, traceContext: traceContext);
Debug.Assert(this._executors.ContainsKey(sourceId));
Executor source = await this.EnsureExecutorAsync(sourceId, tracer: null, cancellationToken).ConfigureAwait(false);
TypeId? declaredType = source.Protocol.SendTypeTranslator.GetDeclaredType(message.GetType());
if (declaredType is null)
{
throw new InvalidOperationException($"Executor '{sourceId}' cannot send messages of type '{message.GetType().FullName}'.");
}
MessageEnvelope envelope = new(message, sourceId, declaredType, targetId: targetId, traceContext: traceContext);
if (this._workflow.Edges.TryGetValue(sourceId, out HashSet<Edge>? edges))
{
foreach (Edge edge in edges)
{
DeliveryMapping? maybeMapping =
await this._edgeMap.PrepareDeliveryForEdgeAsync(edge, envelope)
await this._edgeMap.PrepareDeliveryForEdgeAsync(edge, envelope, cancellationToken)
.ConfigureAwait(false);
maybeMapping?.MapInto(this._nextStep);
@@ -233,6 +241,20 @@ internal sealed class InProcessRunnerContext : IRunnerContext
this.CheckEnded();
Throw.IfNull(output);
// Special-case AgentResponse and AgentResponseUpdate to create their specific event types
// and bypass the output filter (for backwards compatibility - these events were previously
// emitted directly via AddEventAsync without filtering)
if (output is AgentResponseUpdate update)
{
await this.AddEventAsync(new AgentResponseUpdateEvent(sourceId, update), cancellationToken).ConfigureAwait(false);
return;
}
else if (output is AgentResponse response)
{
await this.AddEventAsync(new AgentResponseEvent(sourceId, response), cancellationToken).ConfigureAwait(false);
return;
}
Executor sourceExecutor = await this.EnsureExecutorAsync(sourceId, tracer: null, cancellationToken).ConfigureAwait(false);
if (!sourceExecutor.CanOutput(output.GetType()))
{
@@ -297,12 +319,12 @@ internal sealed class InProcessRunnerContext : IRunnerContext
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
{
return RunnerContext.SendMessageAsync(ExecutorId, message, targetId, cancellationToken);
return RunnerContext.SendMessageAsync(ExecutorId, Throw.IfNull(message), targetId, cancellationToken);
}
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
{
return RunnerContext.YieldOutputAsync(ExecutorId, output, cancellationToken);
return RunnerContext.YieldOutputAsync(ExecutorId, Throw.IfNull(output), cancellationToken);
}
public ValueTask RequestHaltAsync() => this.AddEventAsync(new RequestHaltEvent());
@@ -328,7 +350,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
public bool ConcurrentRunsEnabled => RunnerContext.ConcurrentRunsEnabled;
}
public bool WithCheckpointing { get; }
public bool IsCheckpointingEnabled { get; }
public bool ConcurrentRunsEnabled { get; }
internal Task PrepareForCheckpointAsync(CancellationToken cancellationToken = default)
@@ -416,7 +438,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
{
if (Volatile.Read(ref this._runEnded) == 1)
{
throw new InvalidOperationException($"Workflow run '{this._runId}' has been ended. Please start a new Run or StreamingRun.");
throw new InvalidOperationException($"Workflow run for session '{this._sessionId}' has been ended. Please start a new Run or StreamingRun.");
}
}
@@ -41,35 +41,35 @@ public static class InProcessExecution
/// </summary>
internal static InProcessExecutionEnvironment Subworkflow { get; } = new(ExecutionMode.Subworkflow);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.OpenStreamAsync(Workflow, string?, CancellationToken)"/>
public static ValueTask<StreamingRun> OpenStreamAsync(Workflow workflow, string? runId = null, CancellationToken cancellationToken = default)
=> Default.OpenStreamAsync(workflow, runId, cancellationToken);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.OpenStreamingAsync(Workflow, string?, CancellationToken)"/>
public static ValueTask<StreamingRun> OpenStreamingAsync(Workflow workflow, string? sessionId = null, CancellationToken cancellationToken = default)
=> Default.OpenStreamingAsync(workflow, sessionId, cancellationToken);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.StreamAsync{TInput}(Workflow, TInput, string?, CancellationToken)"/>
public static ValueTask<StreamingRun> StreamAsync<TInput>(Workflow workflow, TInput input, string? runId = null, CancellationToken cancellationToken = default) where TInput : notnull
=> Default.StreamAsync(workflow, input, runId, cancellationToken);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.RunStreamingAsync{TInput}(Workflow, TInput, string?, CancellationToken)"/>
public static ValueTask<StreamingRun> RunStreamingAsync<TInput>(Workflow workflow, TInput input, string? sessionId = null, CancellationToken cancellationToken = default) where TInput : notnull
=> Default.RunStreamingAsync(workflow, input, sessionId, cancellationToken);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.StreamAsync(Workflow, CheckpointManager, string?, CancellationToken)"/>
public static ValueTask<Checkpointed<StreamingRun>> StreamAsync(Workflow workflow, CheckpointManager checkpointManager, string? runId = null, CancellationToken cancellationToken = default)
=> Default.StreamAsync(workflow, checkpointManager, runId, cancellationToken);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.OpenStreamingAsync(Workflow, string?, CancellationToken)"/>
public static ValueTask<StreamingRun> OpenStreamingAsync(Workflow workflow, CheckpointManager checkpointManager, string? sessionId = null, CancellationToken cancellationToken = default)
=> Default.WithCheckpointing(checkpointManager).OpenStreamingAsync(workflow, sessionId, cancellationToken);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.StreamAsync{TInput}(Workflow, TInput, CheckpointManager, string?, CancellationToken)"/>
public static ValueTask<Checkpointed<StreamingRun>> StreamAsync<TInput>(Workflow workflow, TInput input, CheckpointManager checkpointManager, string? runId = null, CancellationToken cancellationToken = default) where TInput : notnull
=> Default.StreamAsync(workflow, input, checkpointManager, runId, cancellationToken);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.RunStreamingAsync{TInput}(Workflow, TInput, string?, CancellationToken)"/>
public static ValueTask<StreamingRun> RunStreamingAsync<TInput>(Workflow workflow, TInput input, CheckpointManager checkpointManager, string? sessionId = null, CancellationToken cancellationToken = default) where TInput : notnull
=> Default.WithCheckpointing(checkpointManager).RunStreamingAsync(workflow, input, sessionId, cancellationToken);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.ResumeStreamAsync(Workflow, CheckpointInfo, CheckpointManager, CancellationToken)"/>
public static ValueTask<Checkpointed<StreamingRun>> ResumeStreamAsync(Workflow workflow, CheckpointInfo fromCheckpoint, CheckpointManager checkpointManager, CancellationToken cancellationToken = default)
=> Default.ResumeStreamAsync(workflow, fromCheckpoint, checkpointManager, cancellationToken);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.ResumeStreamingAsync(Workflow, CheckpointInfo, CancellationToken)"/>
public static ValueTask<StreamingRun> ResumeStreamingAsync(Workflow workflow, CheckpointInfo fromCheckpoint, CheckpointManager checkpointManager, CancellationToken cancellationToken = default)
=> Default.WithCheckpointing(checkpointManager).ResumeStreamingAsync(workflow, fromCheckpoint, cancellationToken);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.RunAsync{TInput}(Workflow, TInput, string?, CancellationToken)"/>
public static ValueTask<Run> RunAsync<TInput>(Workflow workflow, TInput input, string? runId = null, CancellationToken cancellationToken = default) where TInput : notnull
=> Default.RunAsync(workflow, input, runId, cancellationToken);
public static ValueTask<Run> RunAsync<TInput>(Workflow workflow, TInput input, string? sessionId = null, CancellationToken cancellationToken = default) where TInput : notnull
=> Default.RunAsync(workflow, input, sessionId, cancellationToken);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.RunAsync{TInput}(Workflow, TInput, CheckpointManager, string?, CancellationToken)"/>
public static ValueTask<Checkpointed<Run>> RunAsync<TInput>(Workflow workflow, TInput input, CheckpointManager checkpointManager, string? runId = null, CancellationToken cancellationToken = default) where TInput : notnull
=> Default.RunAsync(workflow, input, checkpointManager, runId, cancellationToken);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.RunAsync{TInput}(Workflow, TInput, string?, CancellationToken)"/>
public static ValueTask<Run> RunAsync<TInput>(Workflow workflow, TInput input, CheckpointManager checkpointManager, string? sessionId = null, CancellationToken cancellationToken = default) where TInput : notnull
=> Default.WithCheckpointing(checkpointManager).RunAsync(workflow, input, sessionId, cancellationToken);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.ResumeAsync(Workflow, CheckpointInfo, CheckpointManager, CancellationToken)"/>
public static ValueTask<Checkpointed<Run>> ResumeAsync(Workflow workflow, CheckpointInfo fromCheckpoint, CheckpointManager checkpointManager, CancellationToken cancellationToken = default)
=> Default.ResumeAsync(workflow, fromCheckpoint, checkpointManager, cancellationToken);
/// <inheritdoc cref="IWorkflowExecutionEnvironment.ResumeAsync(Workflow, CheckpointInfo, CancellationToken)"/>
public static ValueTask<Run> ResumeAsync(Workflow workflow, CheckpointInfo fromCheckpoint, CheckpointManager checkpointManager, CancellationToken cancellationToken = default)
=> Default.WithCheckpointing(checkpointManager).ResumeAsync(workflow, fromCheckpoint, cancellationToken);
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<IsReleaseCandidate>true</IsReleaseCandidate>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
</PropertyGroup>
@@ -5,7 +5,8 @@ namespace Microsoft.Agents.AI.Workflows.Observability;
internal static class ActivityNames
{
public const string WorkflowBuild = "workflow.build";
public const string WorkflowRun = "workflow.run";
public const string WorkflowSession = "workflow.session";
public const string WorkflowInvoke = "workflow_invoke";
public const string MessageSend = "message.send";
public const string ExecutorProcess = "executor.process";
public const string EdgeGroupProcess = "edge_group.process";
@@ -8,6 +8,9 @@ internal static class EventNames
public const string BuildValidationCompleted = "build.validation_completed";
public const string BuildCompleted = "build.completed";
public const string BuildError = "build.error";
public const string SessionStarted = "session.started";
public const string SessionCompleted = "session.completed";
public const string SessionError = "session.error";
public const string WorkflowStarted = "workflow.started";
public const string WorkflowCompleted = "workflow.completed";
public const string WorkflowError = "workflow.error";
@@ -11,10 +11,14 @@ internal static class Tags
public const string BuildErrorMessage = "build.error.message";
public const string BuildErrorType = "build.error.type";
public const string ErrorType = "error.type";
public const string RunId = "run.id";
public const string ErrorMessage = "error.message";
public const string SessionId = "session.id";
public const string ExecutorId = "executor.id";
public const string ExecutorType = "executor.type";
public const string ExecutorInput = "executor.input";
public const string ExecutorOutput = "executor.output";
public const string MessageType = "message.type";
public const string MessageContent = "message.content";
public const string EdgeGroupType = "edge_group.type";
public const string MessageSourceId = "message.source_id";
public const string MessageTargetId = "message.target_id";
@@ -0,0 +1,234 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace Microsoft.Agents.AI.Workflows.Observability;
/// <summary>
/// Internal context for workflow telemetry, holding the enabled state and configuration options.
/// </summary>
internal sealed class WorkflowTelemetryContext
{
private const string DefaultSourceName = "Microsoft.Agents.AI.Workflows";
private static readonly ActivitySource s_defaultActivitySource = new(DefaultSourceName);
/// <summary>
/// Gets a shared instance representing disabled telemetry.
/// </summary>
public static WorkflowTelemetryContext Disabled { get; } = new();
/// <summary>
/// Gets a value indicating whether telemetry is enabled.
/// </summary>
public bool IsEnabled { get; }
/// <summary>
/// Gets the telemetry options.
/// </summary>
public WorkflowTelemetryOptions Options { get; }
/// <summary>
/// Gets the activity source used for creating telemetry spans.
/// </summary>
public ActivitySource ActivitySource { get; }
private WorkflowTelemetryContext()
{
this.IsEnabled = false;
this.Options = new WorkflowTelemetryOptions();
this.ActivitySource = s_defaultActivitySource;
}
/// <summary>
/// Initializes a new instance of the <see cref="WorkflowTelemetryContext"/> class with telemetry enabled.
/// </summary>
/// <param name="options">The telemetry options.</param>
/// <param name="activitySource">
/// An optional activity source to use. If provided, this activity source will be used directly
/// and the caller retains ownership (responsible for disposal). If <see langword="null"/>, the
/// shared default activity source will be used.
/// </param>
public WorkflowTelemetryContext(WorkflowTelemetryOptions options, ActivitySource? activitySource = null)
{
this.IsEnabled = true;
this.Options = options;
this.ActivitySource = activitySource ?? s_defaultActivitySource;
}
/// <summary>
/// Starts an activity if telemetry is enabled, otherwise returns null.
/// </summary>
/// <param name="name">The activity name.</param>
/// <param name="kind">The activity kind.</param>
/// <returns>An activity if telemetry is enabled and the activity is sampled, otherwise null.</returns>
public Activity? StartActivity(string name, ActivityKind kind = ActivityKind.Internal)
{
if (!this.IsEnabled)
{
return null;
}
return this.ActivitySource.StartActivity(name, kind);
}
/// <summary>
/// Starts a workflow build activity if enabled.
/// </summary>
/// <returns>An activity if workflow build telemetry is enabled, otherwise null.</returns>
public Activity? StartWorkflowBuildActivity()
{
if (!this.IsEnabled || this.Options.DisableWorkflowBuild)
{
return null;
}
return this.ActivitySource.StartActivity(ActivityNames.WorkflowBuild);
}
/// <summary>
/// Starts a workflow session activity if enabled. This is the outer/parent span
/// that represents the entire lifetime of a workflow execution (from start
/// until stop, cancellation, or error) within the current trace.
/// Individual run stages are typically nested within it.
/// </summary>
/// <returns>An activity if workflow run telemetry is enabled, otherwise null.</returns>
public Activity? StartWorkflowSessionActivity()
{
if (!this.IsEnabled || this.Options.DisableWorkflowRun)
{
return null;
}
return this.ActivitySource.StartActivity(ActivityNames.WorkflowSession);
}
/// <summary>
/// Starts a workflow run activity if enabled. This represents a single
/// input-to-halt cycle within a workflow session.
/// </summary>
/// <returns>An activity if workflow run telemetry is enabled, otherwise null.</returns>
public Activity? StartWorkflowRunActivity()
{
if (!this.IsEnabled || this.Options.DisableWorkflowRun)
{
return null;
}
return this.ActivitySource.StartActivity(ActivityNames.WorkflowInvoke);
}
/// <summary>
/// Starts an executor process activity if enabled, with all standard tags set.
/// </summary>
/// <param name="executorId">The executor identifier.</param>
/// <param name="executorType">The executor type name.</param>
/// <param name="messageType">The message type name.</param>
/// <param name="message">The input message. Logged only when <see cref="WorkflowTelemetryOptions.EnableSensitiveData"/> is true.</param>
/// <returns>An activity if executor process telemetry is enabled, otherwise null.</returns>
public Activity? StartExecutorProcessActivity(string executorId, string? executorType, string messageType, object? message)
{
if (!this.IsEnabled || this.Options.DisableExecutorProcess)
{
return null;
}
Activity? activity = this.ActivitySource.StartActivity(ActivityNames.ExecutorProcess + " " + executorId);
if (activity is null)
{
return null;
}
activity.SetTag(Tags.ExecutorId, executorId)
.SetTag(Tags.ExecutorType, executorType)
.SetTag(Tags.MessageType, messageType);
if (this.Options.EnableSensitiveData)
{
activity.SetTag(Tags.ExecutorInput, SerializeForTelemetry(message));
}
return activity;
}
/// <summary>
/// Sets the executor output tag on an activity when sensitive data logging is enabled.
/// </summary>
/// <param name="activity">The activity to set the output on.</param>
/// <param name="output">The output value to log.</param>
public void SetExecutorOutput(Activity? activity, object? output)
{
if (activity is not null && this.Options.EnableSensitiveData)
{
activity.SetTag(Tags.ExecutorOutput, SerializeForTelemetry(output));
}
}
/// <summary>
/// Starts an edge group process activity if enabled.
/// </summary>
/// <returns>An activity if edge group process telemetry is enabled, otherwise null.</returns>
public Activity? StartEdgeGroupProcessActivity()
{
if (!this.IsEnabled || this.Options.DisableEdgeGroupProcess)
{
return null;
}
return this.ActivitySource.StartActivity(ActivityNames.EdgeGroupProcess);
}
/// <summary>
/// Starts a message send activity if enabled, with all standard tags set.
/// </summary>
/// <param name="sourceId">The source executor identifier.</param>
/// <param name="targetId">The target executor identifier, if any.</param>
/// <param name="message">The message being sent. Logged only when <see cref="WorkflowTelemetryOptions.EnableSensitiveData"/> is true.</param>
/// <returns>An activity if message send telemetry is enabled, otherwise null.</returns>
public Activity? StartMessageSendActivity(string sourceId, string? targetId, object? message)
{
if (!this.IsEnabled || this.Options.DisableMessageSend)
{
return null;
}
Activity? activity = this.ActivitySource.StartActivity(ActivityNames.MessageSend, ActivityKind.Producer);
if (activity is null)
{
return null;
}
activity.SetTag(Tags.MessageSourceId, sourceId);
if (targetId is not null)
{
activity.SetTag(Tags.MessageTargetId, targetId);
}
if (this.Options.EnableSensitiveData)
{
activity.SetTag(Tags.MessageContent, SerializeForTelemetry(message));
}
return activity;
}
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Telemetry serialization is optional and only used when explicitly enabled.")]
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Telemetry serialization is optional and only used when explicitly enabled.")]
private static string? SerializeForTelemetry(object? value)
{
if (value is null)
{
return null;
}
try
{
return JsonSerializer.Serialize(value, value.GetType());
}
catch (JsonException)
{
return $"[Unserializable: {value.GetType().FullName}]";
}
}
}
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows.Observability;
/// <summary>
/// Configuration options for workflow telemetry.
/// </summary>
public sealed class WorkflowTelemetryOptions
{
/// <summary>
/// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry.
/// </summary>
/// <value>
/// <see langword="true"/> if potentially sensitive information should be included in telemetry;
/// <see langword="false"/> if telemetry shouldn't include raw inputs and outputs.
/// The default value is <see langword="false"/>.
/// </value>
/// <remarks>
/// By default, telemetry includes metadata but not raw inputs and outputs,
/// such as message content and executor data.
/// </remarks>
public bool EnableSensitiveData { get; set; }
/// <summary>
/// Gets or sets a value indicating whether workflow build activities should be disabled.
/// </summary>
/// <value>
/// <see langword="true"/> to disable <c>workflow.build</c> activities;
/// <see langword="false"/> to enable them. The default value is <see langword="false"/>.
/// </value>
public bool DisableWorkflowBuild { get; set; }
/// <summary>
/// Gets or sets a value indicating whether workflow run activities should be disabled.
/// </summary>
/// <value>
/// <see langword="true"/> to disable <c>workflow_invoke</c> activities;
/// <see langword="false"/> to enable them. The default value is <see langword="false"/>.
/// </value>
public bool DisableWorkflowRun { get; set; }
/// <summary>
/// Gets or sets a value indicating whether executor process activities should be disabled.
/// </summary>
/// <value>
/// <see langword="true"/> to disable <c>executor.process</c> activities;
/// <see langword="false"/> to enable them. The default value is <see langword="false"/>.
/// </value>
public bool DisableExecutorProcess { get; set; }
/// <summary>
/// Gets or sets a value indicating whether edge group process activities should be disabled.
/// </summary>
/// <value>
/// <see langword="true"/> to disable <c>edge_group.process</c> activities;
/// <see langword="false"/> to enable them. The default value is <see langword="false"/>.
/// </value>
public bool DisableEdgeGroupProcess { get; set; }
/// <summary>
/// Gets or sets a value indicating whether message send activities should be disabled.
/// </summary>
/// <value>
/// <see langword="true"/> to disable <c>message.send</c> activities;
/// <see langword="false"/> to enable them. The default value is <see langword="false"/>.
/// </value>
public bool DisableMessageSend { get; set; }
}
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics;
using Microsoft.Agents.AI.Workflows.Observability;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Provides extension methods for adding OpenTelemetry instrumentation to <see cref="WorkflowBuilder"/> instances.
/// </summary>
public static class OpenTelemetryWorkflowBuilderExtensions
{
/// <summary>
/// Enables OpenTelemetry instrumentation for the workflow, providing comprehensive observability for workflow operations.
/// </summary>
/// <param name="builder">The <see cref="WorkflowBuilder"/> to which OpenTelemetry support will be added.</param>
/// <param name="configure">
/// An optional callback that provides additional configuration of the <see cref="WorkflowTelemetryOptions"/> instance.
/// This allows for fine-tuning telemetry behavior such as enabling sensitive data collection.
/// </param>
/// <param name="activitySource">
/// An optional <see cref="ActivitySource"/> to use for telemetry. If provided, this activity source will be used
/// directly and the caller retains ownership (responsible for disposal). If <see langword="null"/>, a shared
/// default activity source named "Microsoft.Agents.AI.Workflows" will be used.
/// </param>
/// <returns>The <see cref="WorkflowBuilder"/> with OpenTelemetry instrumentation enabled, enabling method chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// This extension adds comprehensive telemetry capabilities to workflows, including:
/// <list type="bullet">
/// <item><description>Distributed tracing of workflow execution</description></item>
/// <item><description>Executor invocation and processing spans</description></item>
/// <item><description>Edge routing and message delivery spans</description></item>
/// <item><description>Workflow build and validation spans</description></item>
/// <item><description>Error tracking and exception details</description></item>
/// </list>
/// </para>
/// <para>
/// By default, workflow telemetry is disabled. Call this method to enable telemetry collection.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// var workflow = new WorkflowBuilder(startExecutor)
/// .AddEdge(executor1, executor2)
/// .WithOpenTelemetry(cfg => cfg.EnableSensitiveData = true)
/// .Build();
/// </code>
/// </example>
public static WorkflowBuilder WithOpenTelemetry(
this WorkflowBuilder builder,
Action<WorkflowTelemetryOptions>? configure = null,
ActivitySource? activitySource = null)
{
Throw.IfNull(builder);
WorkflowTelemetryOptions options = new();
configure?.Invoke(options);
WorkflowTelemetryContext context = new(options, activitySource);
builder.SetTelemetryContext(context);
return builder;
}
}
@@ -96,7 +96,8 @@ public sealed class PortableValue
/// </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, the default value for TValue is returned.
/// Otherwise, the default value for TValue is returned. For value types, the default is not <see langword="null"/>,
/// UNLESS <typeparamref name="TValue"/> is nullable, e.g. <c>int?</c>.
/// </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>
@@ -0,0 +1,174 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
internal static class MemberAttributeExtensions
{
public static (IEnumerable<Type> Sent, IEnumerable<Type> Yielded) GetAttributeTypes(this MemberInfo memberInfo)
{
IEnumerable<SendsMessageAttribute> sendsMessageAttrs = memberInfo.GetCustomAttributes<SendsMessageAttribute>();
IEnumerable<YieldsOutputAttribute> yieldsOutputAttrs = memberInfo.GetCustomAttributes<YieldsOutputAttribute>();
// TODO: Should we include [MessageHandler]?
return (Sent: sendsMessageAttrs.Select(attr => attr.Type), Yielded: yieldsOutputAttrs.Select(attr => attr.Type));
}
}
/// <summary>
/// .
/// </summary>
public sealed class ProtocolBuilder
{
private readonly HashSet<Type> _sendTypes = [];
private readonly HashSet<Type> _yieldTypes = [];
internal ProtocolBuilder(DelayedExternalRequestContext delayRequestContext)
{
this.RouteBuilder = new RouteBuilder(delayRequestContext);
}
/// <summary>
/// Adds types registered in <see cref="SendsMessageAttribute"/> or <see cref="YieldsOutputAttribute"/>
/// on the target <see cref="Delegate"/>. This can be used to implement delegate-based request handling akin
/// to what is provided by <see cref="Executor{TInput}"/> or <see cref="Executor{TIn,TOut}"/>.
/// </summary>
/// <param name="delegate">The delegate to be registered.</param>
/// <returns></returns>
public ProtocolBuilder AddDelegateAttributeTypes(Delegate @delegate)
=> this.AddMethodAttributeTypes(Throw.IfNull(@delegate).Method);
/// <summary>
/// Adds types registered in <see cref="SendsMessageAttribute"/> or <see cref="YieldsOutputAttribute"/>
/// on the target <see cref="MethodInfo"/>. This can be used to implement delegate-based request handling akin
/// to what is provided by <see cref="Executor{TInput}"/> or <see cref="Executor{TIn,TOut}"/>.
/// </summary>
/// <param name="method">The method to be registered.</param>
/// <returns></returns>
public ProtocolBuilder AddMethodAttributeTypes(MethodInfo method)
{
(IEnumerable<Type> sentTypes, IEnumerable<Type> yieldTypes) = method.GetAttributeTypes();
this._sendTypes.UnionWith(sentTypes);
this._yieldTypes.UnionWith(yieldTypes);
return method.DeclaringType != null ? this.AddClassAttributeTypes(method.DeclaringType)
: this;
}
/// <summary>
/// Adds types registered in <see cref="SendsMessageAttribute"/> or <see cref="YieldsOutputAttribute"/>
/// on the target <see cref="Type"/>. This can be used to implement delegate-based request handling akin
/// to what is provided by <see cref="Executor{TInput}"/> or <see cref="Executor{TIn,TOut}"/>.
/// </summary>
/// <param name="executorType">The type to be registered.</param>
/// <returns></returns>
public ProtocolBuilder AddClassAttributeTypes(Type executorType)
{
(IEnumerable<Type> sentTypes, IEnumerable<Type> yieldTypes) = executorType.GetAttributeTypes();
this._sendTypes.UnionWith(sentTypes);
this._yieldTypes.UnionWith(yieldTypes);
return this;
}
/// <summary>
/// Adds the specified type to the set of declared "sent" message types for the protocol. Objects of these types will be allowed to be
/// sent through the Executor's outgoing edges, via <see cref="IWorkflowContext.SendMessageAsync"/>.
/// </summary>
/// <typeparam name="TMessage">The type to be declared.</typeparam>
/// <returns></returns>
public ProtocolBuilder SendsMessage<TMessage>() where TMessage : notnull => this.SendsMessageTypes([typeof(TMessage)]);
/// <summary>
/// Adds the specified type to the set of declared "sent" messagetypes for the protocol. Objects of these types will be allowed to be
/// sent through the Executor's outgoing edges, via <see cref="IWorkflowContext.SendMessageAsync"/>.
/// </summary>
/// <param name="messageType">The type to be declared.</param>
/// <returns></returns>
public ProtocolBuilder SendsMessageType(Type messageType) => this.SendsMessageTypes([messageType]);
/// <summary>
/// Adds the specified types to the set of declared "sent" message types for the protocol. Objects of these types will be allowed to be
/// sent through the Executor's outgoing edges, via <see cref="IWorkflowContext.SendMessageAsync"/>.
/// </summary>
/// <param name="messageTypes">A set of types to be declared.</param>
/// <returns></returns>
public ProtocolBuilder SendsMessageTypes(IEnumerable<Type> messageTypes)
{
Throw.IfNull(messageTypes);
this._sendTypes.UnionWith(messageTypes);
return this;
}
/// <summary>
/// Adds the specified output type to the set of declared "yielded" output types for the protocol. Objects of this type will be
/// allowed to be output from the executor through the <see cref="WorkflowOutputEvent"/>, via <see cref="IWorkflowContext.YieldOutputAsync"/>.
/// </summary>
/// <typeparam name="TOutput">The type to be declared.</typeparam>
/// <returns></returns>
public ProtocolBuilder YieldsOutput<TOutput>() where TOutput : notnull => this.YieldsOutputTypes([typeof(TOutput)]);
/// <summary>
/// Adds the specified output type to the set of declared "yielded" output types for the protocol. Objects of this type will be
/// allowed to be output from the executor through the <see cref="WorkflowOutputEvent"/>, via <see cref="IWorkflowContext.YieldOutputAsync"/>.
/// </summary>
/// <param name="outputType">The type to be declared.</param>
/// <returns></returns>
public ProtocolBuilder YieldsOutputType(Type outputType) => this.YieldsOutputTypes([outputType]);
/// <summary>
/// Adds the specified types to the set of declared "yielded" output types for the protocol. Objects of these types will be allowed to be
/// output from the executor through the <see cref="WorkflowOutputEvent"/>, via <see cref="IWorkflowContext.YieldOutputAsync"/>.
/// </summary>
/// <param name="yieldedTypes">A set of types to be declared.</param>
/// <returns></returns>
public ProtocolBuilder YieldsOutputTypes(IEnumerable<Type> yieldedTypes)
{
Throw.IfNull(yieldedTypes);
this._yieldTypes.UnionWith(yieldedTypes);
return this;
}
/// <summary>
/// Gets a route builder to configure message handlers.
/// </summary>
public RouteBuilder RouteBuilder { get; }
/// <summary>
/// Fluently configures message handlers.
/// </summary>
/// <param name="configureAction">The handler configuration callback.</param>
/// <returns></returns>
public ProtocolBuilder ConfigureRoutes(Action<RouteBuilder> configureAction)
{
configureAction(this.RouteBuilder);
return this;
}
internal ExecutorProtocol Build(ExecutorOptions options)
{
MessageRouter router = this.RouteBuilder.Build();
HashSet<Type> sendTypes = new(this._sendTypes);
if (options.AutoSendMessageHandlerResultObject)
{
sendTypes.UnionWith(router.DefaultOutputTypes);
}
HashSet<Type> yieldTypes = new(this._yieldTypes);
if (options.AutoYieldOutputHandlerResultObject)
{
yieldTypes.UnionWith(router.DefaultOutputTypes);
}
return new(router, sendTypes, yieldTypes);
}
}
@@ -16,14 +16,27 @@ public class ProtocolDescriptor
/// </summary>
public IEnumerable<Type> Accepts { get; }
/// <summary>
/// Gets the collection of types that could be yielded as output by the <see cref="Workflow"/> or <see cref="Executor"/>.
/// </summary>
public IEnumerable<Type> Yields { get; }
/// <summary>
/// Gets the collection of types that could be sent from the <see cref="Executor"/>. This is always empty for a <see cref="Workflow"/>.
/// </summary>
public IEnumerable<Type> Sends { get; }
/// <summary>
/// Gets a value indicating whether the <see cref="Workflow"/> or <see cref="Executor"/> has a "catch-all" handler.
/// </summary>
public bool AcceptsAll { get; set; }
internal ProtocolDescriptor(IEnumerable<Type> acceptedTypes, bool acceptsAll)
internal ProtocolDescriptor(IEnumerable<Type> acceptedTypes, IEnumerable<Type> yieldedTypes, IEnumerable<Type> sentTypes, bool acceptsAll)
{
this.Accepts = acceptedTypes.ToArray();
this.Yields = yieldedTypes.ToArray();
this.Sends = sentTypes.ToArray();
this.AcceptsAll = acceptsAll;
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -9,6 +10,12 @@ namespace Microsoft.Agents.AI.Workflows.Reflection;
/// A message handler interface for handling messages of type <typeparamref name="TMessage"/>.
/// </summary>
/// <typeparam name="TMessage"></typeparam>
/// <remarks>
/// This interface is obsolete. Use the <see cref="MessageHandlerAttribute"/> on methods in a partial class
/// deriving from <see cref="Executor"/> instead.
/// </remarks>
[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " +
"This interface will be removed in a future version.")]
public interface IMessageHandler<TMessage>
{
/// <summary>
@@ -28,6 +35,12 @@ public interface IMessageHandler<TMessage>
/// </summary>
/// <typeparam name="TMessage">The type of message to handle.</typeparam>
/// <typeparam name="TResult">The type of result returned after handling the message.</typeparam>
/// <remarks>
/// This interface is obsolete. Use the <see cref="MessageHandlerAttribute"/> on methods in a partial class
/// deriving from <see cref="Executor"/> instead.
/// </remarks>
[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " +
"This interface will be removed in a future version.")]
public interface IMessageHandler<TMessage, TResult>
{
/// <summary>
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
@@ -1,6 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
namespace Microsoft.Agents.AI.Workflows.Reflection;
@@ -10,6 +14,12 @@ namespace Microsoft.Agents.AI.Workflows.Reflection;
/// <typeparam name="TExecutor">The actual type of the <see cref="ReflectingExecutor{TExecutor}"/>.
/// This is used to reflectively discover handlers for messages without violating ILTrim requirements.
/// </typeparam>
/// <remarks>
/// This type is obsolete. Use the <see cref="MessageHandlerAttribute"/> on methods in a partial class
/// deriving from <see cref="Executor"/> instead.
/// </remarks>
[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " +
"This type will be removed in a future version.")]
public class ReflectingExecutor<
[DynamicallyAccessedMembers(
ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation)
@@ -22,7 +32,45 @@ public class ReflectingExecutor<
{
}
/// <inheritdoc />
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.ReflectHandlers(this);
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
protocolBuilder.SendsMessageTypes(typeof(TExecutor).GetCustomAttributes<SendsMessageAttribute>(inherit: true)
.Select(attr => attr.Type))
.YieldsOutputTypes(typeof(TExecutor).GetCustomAttributes<YieldsOutputAttribute>(inherit: true)
.Select(attr => attr.Type));
List<MessageHandlerInfo> messageHandlers = typeof(TExecutor).GetHandlerInfos().ToList();
foreach (MessageHandlerInfo handlerInfo in messageHandlers)
{
protocolBuilder.RouteBuilder.AddHandlerInternal(handlerInfo.InType, handlerInfo.Bind(this, checkType: true), handlerInfo.OutType);
if (handlerInfo.OutType != null)
{
if (this.Options.AutoSendMessageHandlerResultObject)
{
protocolBuilder.SendsMessageType(handlerInfo.OutType);
}
if (this.Options.AutoYieldOutputHandlerResultObject)
{
protocolBuilder.YieldsOutputType(handlerInfo.OutType);
}
}
}
if (messageHandlers.Count > 0)
{
var handlerAnnotatedTypes =
messageHandlers.Select(mhi => (SendTypes: mhi.HandlerInfo.GetCustomAttributes<SendsMessageAttribute>().Select(attr => attr.Type),
YieldTypes: mhi.HandlerInfo.GetCustomAttributes<YieldsOutputAttribute>().Select(attr => attr.Type)))
.Aggregate((accumulate, next) => (accumulate.SendTypes == null ? next.SendTypes : accumulate.SendTypes.Concat(next.SendTypes),
accumulate.YieldTypes == null ? next.YieldTypes : accumulate.YieldTypes.Concat(next.YieldTypes)));
protocolBuilder.SendsMessageTypes(handlerAnnotatedTypes.SendTypes)
.YieldsOutputTypes(handlerAnnotatedTypes.YieldTypes);
}
return protocolBuilder;
}
}
@@ -1,11 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Reflection;
@@ -43,7 +44,7 @@ internal static class IMessageHandlerReflection
internal static class RouteBuilderExtensions
{
private static IEnumerable<MessageHandlerInfo> GetHandlerInfos(
public static IEnumerable<MessageHandlerInfo> GetHandlerInfos(
[DynamicallyAccessedMembers(ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation)]
this Type executorType)
{
@@ -75,25 +76,4 @@ internal static class RouteBuilderExtensions
}
}
}
public static RouteBuilder ReflectHandlers<
[DynamicallyAccessedMembers(
ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation)
] TExecutor>
(this RouteBuilder builder, ReflectingExecutor<TExecutor> executor)
where TExecutor : ReflectingExecutor<TExecutor>
{
Throw.IfNull(builder);
Type executorType = typeof(TExecutor);
Debug.Assert(executorType.IsInstanceOfType(executor),
"executorType must be the same type or a base type of the executor instance.");
foreach (MessageHandlerInfo handlerInfo in executorType.GetHandlerInfos())
{
builder = builder.AddHandlerInternal(handlerInfo.InType, handlerInfo.Bind(executor, checkType: true), handlerInfo.OutType);
}
return builder;
}
}
@@ -35,9 +35,6 @@ namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Provides a builder for configuring message type handlers for an <see cref="Executor"/>.
/// </summary>
/// <remarks>
/// Override the <see cref="Executor.ConfigureRoutes"/> method to customize the routing of messages to handlers.
/// </remarks>
public class RouteBuilder
{
private readonly IExternalRequestContext? _externalRequestContext;
@@ -161,7 +158,7 @@ public class RouteBuilder
async ValueTask<ExternalResponse?> InvokeHandlerAsync(ExternalResponse response, IWorkflowContext context, CancellationToken cancellationToken)
{
if (!response.DataIs(out TResponse? typedResponse))
if (!response.TryGetDataAs(out TResponse? typedResponse))
{
throw new InvalidOperationException($"Received response data is not of expected type {typeof(TResponse).FullName} for port {port.Id}.");
}
@@ -631,6 +628,8 @@ public class RouteBuilder
}
}
internal IEnumerable<Type> OutputTypes => this._outputTypes.Values;
internal MessageRouter Build()
{
if (this._portHandlers.Count > 0)
@@ -14,13 +14,13 @@ namespace Microsoft.Agents.AI.Workflows;
/// Represents a workflow run that tracks execution status and emitted workflow events, supporting resumption
/// with responses to <see cref="RequestInfoEvent"/>.
/// </summary>
public sealed class Run : IAsyncDisposable
public sealed class Run : CheckpointableRunBase, IAsyncDisposable
{
private readonly List<WorkflowEvent> _eventSink = [];
private readonly AsyncRunHandle _runHandle;
internal Run(AsyncRunHandle _runHandle)
internal Run(AsyncRunHandle runHandle) : base(runHandle)
{
this._runHandle = _runHandle;
this._runHandle = runHandle;
}
internal async ValueTask<bool> RunToNextHaltAsync(CancellationToken cancellationToken = default)
@@ -36,9 +36,9 @@ public sealed class Run : IAsyncDisposable
}
/// <summary>
/// A unique identifier for the run. Can be provided at the start of the run, or auto-generated.
/// A unique identifier for the session. Can be provided at the start of the session, or auto-generated.
/// </summary>
public string RunId => this._runHandle.RunId;
public string SessionId => this._runHandle.SessionId;
/// <summary>
/// Gets the current execution status of the workflow run.
@@ -36,27 +36,26 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
this._options = options;
}
private RouteBuilder ConfigureUserInputRoutes(RouteBuilder routeBuilder)
private ProtocolBuilder ConfigureUserInputHandling(ProtocolBuilder protocolBuilder)
{
this._userInputHandler = new AIContentExternalHandler<UserInputRequestContent, UserInputResponseContent>(
ref routeBuilder,
ref protocolBuilder,
portId: $"{this.Id}_UserInput",
intercepted: this._options.InterceptUserInputRequests,
handler: this.HandleUserInputResponseAsync);
this._functionCallHandler = new AIContentExternalHandler<FunctionCallContent, FunctionResultContent>(
ref routeBuilder,
ref protocolBuilder,
portId: $"{this.Id}_FunctionCall",
intercepted: this._options.InterceptUnterminatedFunctionCalls,
handler: this.HandleFunctionResultAsync);
return routeBuilder;
return protocolBuilder;
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
routeBuilder = base.ConfigureRoutes(routeBuilder);
return this.ConfigureUserInputRoutes(routeBuilder);
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder));
}
private ValueTask HandleUserInputResponseAsync(
@@ -93,7 +92,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
=> emitEvents ?? this._options.EmitAgentUpdateEvents ?? false;
private async ValueTask<AgentSession> EnsureSessionAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
this._session ??= await this._agent.GetNewSessionAsync(cancellationToken).ConfigureAwait(false);
this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
private const string UserInputRequestStateKey = nameof(_userInputHandler);
private const string FunctionCallRequestStateKey = nameof(_functionCallHandler);
@@ -101,7 +100,8 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
AIAgentHostState state = new(this._session?.Serialize(), this._currentTurnEmitEvents);
JsonElement? sessionState = this._session is not null ? await this._agent.SerializeSessionAsync(this._session, cancellationToken: cancellationToken).ConfigureAwait(false) : null;
AIAgentHostState state = new(sessionState, 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;
@@ -180,7 +180,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
List<AgentResponseUpdate> updates = [];
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
ExtractUnservicedRequests(update.Contents);
updates.Add(update);
}
@@ -200,7 +200,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
if (this._options.EmitAgentResponseEvents == true)
{
await context.AddEventAsync(new AgentResponseEvent(this.Id, response), cancellationToken).ConfigureAwait(false);
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
}
if (userInputRequests.Count > 0 || functionCalls.Count > 0)
@@ -18,16 +18,28 @@ internal sealed class AIContentExternalHandler<TRequestContent, TResponseContent
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)
public AIContentExternalHandler(ref ProtocolBuilder protocolBuilder, string portId, bool intercepted, Func<TResponseContent, IWorkflowContext, CancellationToken, ValueTask> handler)
{
PortBinding? portBinding = null;
protocolBuilder = protocolBuilder.ConfigureRoutes(routeBuilder => ConfigureRoutes(routeBuilder, out portBinding));
this._portBinding = portBinding;
if (intercepted)
{
this._portBinding = null;
routeBuilder = routeBuilder.AddHandler(handler);
protocolBuilder = protocolBuilder.SendsMessage<TRequestContent>();
}
else
void ConfigureRoutes(RouteBuilder routeBuilder, out PortBinding? portBinding)
{
routeBuilder = routeBuilder.AddPortHandler<TRequestContent, TResponseContent>(portId, handler, out this._portBinding);
if (intercepted)
{
portBinding = null;
routeBuilder.AddHandler(handler);
}
else
{
routeBuilder.AddPortHandler<TRequestContent, TResponseContent>(portId, handler, out portBinding);
}
}
}
@@ -11,8 +11,10 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
/// Provides an executor that aggregates received chat messages that it then releases when
/// receiving a <see cref="TurnToken"/>.
/// </summary>
internal sealed class AggregateTurnMessagesExecutor(string id) : ChatProtocolExecutor(id, declareCrossRunShareable: true), IResettableExecutor
internal sealed class AggregateTurnMessagesExecutor(string id) : ChatProtocolExecutor(id, s_options, declareCrossRunShareable: true), IResettableExecutor
{
private static readonly ChatProtocolExecutorOptions s_options = new() { AutoSendTurnToken = false };
/// <inheritdoc/>
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> context.SendMessageAsync(messages, cancellationToken: cancellationToken);
@@ -36,8 +36,9 @@ internal sealed class ConcurrentEndExecutor : Executor, IResettableExecutor
this._remaining = this._expectedInputs;
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<List<ChatMessage>>(async (messages, context, cancellationToken) =>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
protocolBuilder.RouteBuilder.AddHandler<List<ChatMessage>>(async (messages, context, cancellationToken) =>
{
// TODO: https://github.com/microsoft/agent-framework/issues/784
// This locking should not be necessary.
@@ -58,6 +59,9 @@ internal sealed class ConcurrentEndExecutor : Executor, IResettableExecutor
}
});
return protocolBuilder.YieldsOutput<List<ChatMessage>>();
}
public ValueTask ResetAsync()
{
this.Reset();
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -11,52 +12,51 @@ internal sealed class GroupChatHost(
string id,
AIAgent[] agents,
Dictionary<AIAgent, ExecutorBinding> agentMap,
Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) : Executor(id), IResettableExecutor
Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) : ChatProtocolExecutor(id, s_options), IResettableExecutor
{
private static readonly ChatProtocolExecutorOptions s_options = new()
{
StringMessageChatRole = ChatRole.User,
AutoSendTurnToken = false
};
private readonly AIAgent[] _agents = agents;
private readonly Dictionary<AIAgent, ExecutorBinding> _agentMap = agentMap;
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory = managerFactory;
private readonly List<ChatMessage> _pendingMessages = [];
private GroupChatManager? _manager;
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder
.AddHandler<string>((message, context, _) => this._pendingMessages.Add(new(ChatRole.User, message)))
.AddHandler<ChatMessage>((message, context, _) => this._pendingMessages.Add(message))
.AddHandler<IEnumerable<ChatMessage>>((messages, _, __) => this._pendingMessages.AddRange(messages))
.AddHandler<ChatMessage[]>((messages, _, __) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed
.AddHandler<List<ChatMessage>>((messages, _, __) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed
.AddHandler<TurnToken>(async (token, context, cancellationToken) =>
{
List<ChatMessage> messages = [.. this._pendingMessages];
this._pendingMessages.Clear();
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> base.ConfigureProtocol(protocolBuilder).YieldsOutput<List<ChatMessage>>();
this._manager ??= this._managerFactory(this._agents);
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
{
this._manager ??= this._managerFactory(this._agents);
if (!await this._manager.ShouldTerminateAsync(messages, cancellationToken).ConfigureAwait(false))
if (!await this._manager.ShouldTerminateAsync(messages, cancellationToken).ConfigureAwait(false))
{
var filtered = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false);
messages = filtered is null || ReferenceEquals(filtered, messages) ? messages : [.. filtered];
if (await this._manager.SelectNextAgentAsync(messages, cancellationToken).ConfigureAwait(false) is AIAgent nextAgent &&
this._agentMap.TryGetValue(nextAgent, out var executor))
{
var filtered = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false);
messages = filtered is null || ReferenceEquals(filtered, messages) ? messages : [.. filtered];
if (await this._manager.SelectNextAgentAsync(messages, cancellationToken).ConfigureAwait(false) is AIAgent nextAgent &&
this._agentMap.TryGetValue(nextAgent, out var executor))
{
this._manager.IterationCount++;
await context.SendMessageAsync(messages, executor.Id, cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(token, executor.Id, cancellationToken).ConfigureAwait(false);
return;
}
this._manager.IterationCount++;
await context.SendMessageAsync(messages, executor.Id, cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(new TurnToken(emitEvents), executor.Id, cancellationToken).ConfigureAwait(false);
return;
}
this._manager = null;
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
});
}
public ValueTask ResetAsync()
this._manager = null;
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
}
protected override ValueTask ResetAsync()
{
this._pendingMessages.Clear();
this._manager = null;
return default;
return base.ResetAsync();
}
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
}
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -11,10 +12,155 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class HandoffAgentExecutorOptions
{
public HandoffAgentExecutorOptions(string? handoffInstructions, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
{
this.HandoffInstructions = handoffInstructions;
this.ToolCallFilteringBehavior = toolCallFilteringBehavior;
}
public string? HandoffInstructions { get; set; }
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
}
internal sealed class HandoffMessagesFilter
{
private readonly HandoffToolCallFilteringBehavior _filteringBehavior;
public HandoffMessagesFilter(HandoffToolCallFilteringBehavior filteringBehavior)
{
this._filteringBehavior = filteringBehavior;
}
internal static bool IsHandoffFunctionName(string name)
{
return name.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
}
public IEnumerable<ChatMessage> FilterMessages(List<ChatMessage> messages)
{
if (this._filteringBehavior == HandoffToolCallFilteringBehavior.None)
{
return messages;
}
Dictionary<string, FilterCandidateState> filteringCandidates = new();
List<ChatMessage> filteredMessages = [];
HashSet<int> messagesToRemove = [];
bool filterHandoffOnly = this._filteringBehavior == HandoffToolCallFilteringBehavior.HandoffOnly;
foreach (ChatMessage unfilteredMessage in messages)
{
ChatMessage filteredMessage = unfilteredMessage.Clone();
// .Clone() is shallow, so we cannot modify the contents of the cloned message in place.
List<AIContent> contents = [];
contents.Capacity = unfilteredMessage.Contents?.Count ?? 0;
filteredMessage.Contents = contents;
// Because this runs after the role changes from assistant to user for the target agent, we cannot rely on tool calls
// originating only from messages with the Assistant role. Instead, we need to inspect the contents of all non-Tool (result)
// FunctionCallContent.
if (unfilteredMessage.Role != ChatRole.Tool)
{
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
{
AIContent content = unfilteredMessage.Contents[i];
if (content is not FunctionCallContent fcc || (filterHandoffOnly && !IsHandoffFunctionName(fcc.Name)))
{
filteredMessage.Contents.Add(content);
// Track non-handoff function calls so their tool results are preserved in HandoffOnly mode
if (filterHandoffOnly && content is FunctionCallContent nonHandoffFcc)
{
filteringCandidates[nonHandoffFcc.CallId] = new FilterCandidateState(nonHandoffFcc.CallId)
{
IsHandoffFunction = false,
};
}
}
else if (filterHandoffOnly)
{
if (!filteringCandidates.TryGetValue(fcc.CallId, out FilterCandidateState? candidateState))
{
filteringCandidates[fcc.CallId] = new FilterCandidateState(fcc.CallId)
{
IsHandoffFunction = true,
};
}
else
{
candidateState.IsHandoffFunction = true;
(int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value;
ChatMessage messageToFilter = filteredMessages[messageIndex];
messageToFilter.Contents.RemoveAt(contentIndex);
if (messageToFilter.Contents.Count == 0)
{
messagesToRemove.Add(messageIndex);
}
}
}
else
{
// All mode: strip all FunctionCallContent
}
}
}
else
{
if (!filterHandoffOnly)
{
continue;
}
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
{
AIContent content = unfilteredMessage.Contents[i];
if (content is not FunctionResultContent frc
|| (filteringCandidates.TryGetValue(frc.CallId, out FilterCandidateState? candidateState)
&& candidateState.IsHandoffFunction is false))
{
// Either this is not a function result content, so we should let it through, or it is a FRC that
// we know is not related to a handoff call. In either case, we should include it.
filteredMessage.Contents.Add(content);
}
else if (candidateState is null)
{
// We haven't seen the corresponding function call yet, so add it as a candidate to be filtered later
filteringCandidates[frc.CallId] = new FilterCandidateState(frc.CallId)
{
FunctionCallResultLocation = (filteredMessages.Count, filteredMessage.Contents.Count),
};
}
// else we have seen the corresponding function call and it is a handoff, so we should filter it out.
}
}
if (filteredMessage.Contents.Count > 0)
{
filteredMessages.Add(filteredMessage);
}
}
return filteredMessages.Where((_, index) => !messagesToRemove.Contains(index));
}
private class FilterCandidateState(string callId)
{
public (int MessageIndex, int ContentIndex)? FunctionCallResultLocation { get; set; }
public string CallId => callId;
public bool? IsHandoffFunction { get; set; }
}
}
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
internal sealed class HandoffAgentExecutor(
AIAgent agent,
string? handoffInstructions) : Executor(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor
HandoffAgentExecutorOptions options) : Executor<HandoffState, HandoffState>(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor
{
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
@@ -38,7 +184,7 @@ internal sealed class HandoffAgentExecutor(
ChatOptions = new()
{
AllowMultipleToolCalls = false,
Instructions = handoffInstructions,
Instructions = options.HandoffInstructions,
Tools = [],
},
};
@@ -60,59 +206,65 @@ internal sealed class HandoffAgentExecutor(
sb.WithDefault(end);
});
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<HandoffState>(async (handoffState, context, cancellationToken) =>
public override async ValueTask<HandoffState> HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
string? requestedHandoff = null;
List<AgentResponseUpdate> updates = [];
List<ChatMessage> allMessages = message.Messages;
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
// If a handoff was invoked by a previous agent, filter out the handoff function
// call and tool result messages before sending to the underlying agent. These
// are internal workflow mechanics that confuse the target model into ignoring the
// original user question.
HandoffMessagesFilter handoffMessagesFilter = new(options.ToolCallFilteringBehavior);
IEnumerable<ChatMessage> messagesForAgent = message.InvokedHandoff is not null
? handoffMessagesFilter.FilterMessages(allMessages)
: allMessages;
await foreach (var update in this._agent.RunStreamingAsync(messagesForAgent,
options: this._agentOptions,
cancellationToken: cancellationToken)
.ConfigureAwait(false))
{
string? requestedHandoff = null;
List<AgentResponseUpdate> updates = [];
List<ChatMessage> allMessages = handoffState.Messages;
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
await foreach (var update in this._agent.RunStreamingAsync(allMessages,
options: this._agentOptions,
cancellationToken: cancellationToken)
.ConfigureAwait(false))
foreach (var fcc in update.Contents.OfType<FunctionCallContent>()
.Where(fcc => this._handoffFunctionNames.Contains(fcc.Name)))
{
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
foreach (var c in update.Contents)
{
if (c is FunctionCallContent fcc && this._handoffFunctionNames.Contains(fcc.Name))
{
requestedHandoff = fcc.Name;
await AddUpdateAsync(
new AgentResponseUpdate
{
AgentId = this._agent.Id,
AuthorName = this._agent.Name ?? this._agent.Id,
Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Tool,
},
cancellationToken
)
.ConfigureAwait(false);
}
}
requestedHandoff = fcc.Name;
await AddUpdateAsync(
new AgentResponseUpdate
{
AgentId = this._agent.Id,
AuthorName = this._agent.Name ?? this._agent.Id,
Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Tool,
},
cancellationToken
)
.ConfigureAwait(false);
}
}
allMessages.AddRange(updates.ToAgentResponse().Messages);
allMessages.AddRange(updates.ToAgentResponse().Messages);
roleChanges.ResetUserToAssistantForChangedRoles();
roleChanges.ResetUserToAssistantForChangedRoles();
await context.SendMessageAsync(new HandoffState(handoffState.TurnToken, requestedHandoff, allMessages), cancellationToken: cancellationToken).ConfigureAwait(false);
return new(message.TurnToken, requestedHandoff, allMessages);
async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
{
updates.Add(update);
if (message.TurnToken.EmitEvents is true)
{
updates.Add(update);
if (handoffState.TurnToken.EmitEvents is true)
{
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
}
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
}
});
}
}
public ValueTask ResetAsync() => default;
}
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
@@ -9,9 +11,10 @@ internal sealed class HandoffsEndExecutor() : Executor(ExecutorId, declareCrossR
{
public const string ExecutorId = "HandoffEnd";
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<HandoffState>((handoff, context, cancellationToken) =>
context.YieldOutputAsync(handoff.Messages, cancellationToken));
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>((handoff, context, cancellationToken) =>
context.YieldOutputAsync(handoff.Messages, cancellationToken)))
.YieldsOutput<List<ChatMessage>>();
public ValueTask ResetAsync() => default;
}
@@ -14,9 +14,13 @@ internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId,
private static ChatProtocolExecutorOptions DefaultOptions => new()
{
StringMessageChatRole = ChatRole.User
StringMessageChatRole = ChatRole.User,
AutoSendTurnToken = false
};
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
base.ConfigureProtocol(protocolBuilder).SendsMessage<HandoffState>();
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages), cancellationToken: cancellationToken);
@@ -15,6 +15,10 @@ internal sealed class OutputMessagesExecutor(ChatProtocolExecutorOptions? option
{
public const string ExecutorId = "OutputMessages";
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
base.ConfigureProtocol(protocolBuilder)
.YieldsOutput<List<ChatMessage>>();
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> context.YieldOutputAsync(messages, cancellationToken);
@@ -34,22 +34,29 @@ internal sealed class RequestInfoExecutor : Executor
this._allowWrapped = allowWrapped;
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
routeBuilder = routeBuilder
// Handle incoming requests (as raw request payloads)
.AddHandlerUntyped(this.Port.Request, this.HandleAsync)
.AddCatchAll(this.HandleCatchAllAsync);
return protocolBuilder.ConfigureRoutes(ConfigureRoutes)
.SendsMessage<ExternalRequest>()
.SendsMessageType(this.Port.Response);
if (this._allowWrapped)
void ConfigureRoutes(RouteBuilder routeBuilder)
{
routeBuilder = routeBuilder
.AddHandler<ExternalRequest, ExternalRequest>(this.HandleAsync);
}
// Handle incoming requests (as raw request payloads)
.AddHandlerUntyped(this.Port.Request, this.HandleAsync)
.AddCatchAll(this.HandleCatchAllAsync);
return routeBuilder
// Handle incoming responses (as wrapped Response object)
.AddHandler<ExternalResponse, ExternalResponse?>(this.HandleAsync);
if (this._allowWrapped)
{
routeBuilder = routeBuilder
.AddHandler<ExternalRequest, ExternalRequest>(this.HandleAsync);
}
routeBuilder
// Handle incoming responses (as wrapped Response object)
.AddHandler<ExternalResponse, ExternalResponse?>(this.HandleAsync);
}
}
internal void AttachRequestSink(IExternalRequestSink requestSink) => this.RequestSink = Throw.IfNull(requestSink);
@@ -15,8 +15,9 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal class WorkflowHostExecutor : Executor, IAsyncDisposable
{
private readonly string _runId;
private readonly string _sessionId;
private readonly Workflow _workflow;
private readonly ProtocolDescriptor _workflowProtocol;
private readonly object _ownershipToken;
private InProcessRunner? _activeRunner;
@@ -30,19 +31,25 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
[MemberNotNullWhen(true, nameof(_checkpointManager))]
private bool WithCheckpointing => this._checkpointManager != null;
public WorkflowHostExecutor(string id, Workflow workflow, string runId, object ownershipToken, ExecutorOptions? options = null) : base(id, options)
public WorkflowHostExecutor(string id, Workflow workflow, ProtocolDescriptor workflowProtocol, string sessionId, object ownershipToken, ExecutorOptions? options = null) : base(id, options)
{
this._options = options ?? new();
Throw.IfNull(workflow);
this._runId = Throw.IfNull(runId);
this._sessionId = Throw.IfNull(sessionId);
this._ownershipToken = Throw.IfNull(ownershipToken);
this._workflow = Throw.IfNull(workflow);
this._workflowProtocol = Throw.IfNull(workflowProtocol);
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return routeBuilder.AddCatchAll(this.QueueExternalMessageAsync);
if (this._options.AutoYieldOutputHandlerResultObject)
{
protocolBuilder = protocolBuilder.YieldsOutputTypes(this._workflowProtocol.Yields);
}
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddCatchAll(this.QueueExternalMessageAsync))
.SendsMessageTypes(this._workflowProtocol.Yields);
}
private async ValueTask QueueExternalMessageAsync(PortableValue portableValue, IWorkflowContext context, CancellationToken cancellationToken)
@@ -73,7 +80,7 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
{
if (this._activeRunner == null)
{
if (this.JoinContext.WithCheckpointing)
if (this.JoinContext.IsCheckpointingEnabled)
{
// Use a seprate in-memory checkpoint manager for scoping purposes. We do not need to worry about
// serialization because we will be relying on the parent workflow's checkpoint manager to do that,
@@ -84,7 +91,7 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
this._activeRunner = InProcessRunner.CreateSubworkflowRunner(this._workflow,
this._checkpointManager,
this._runId,
this._sessionId,
this._ownershipToken,
this.JoinContext.ConcurrentRunsEnabled);
}
@@ -114,7 +121,7 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
if (resume)
{
// Attempting to resume from checkpoint
if (!this._checkpointManager.TryGetLastCheckpoint(this._runId, out CheckpointInfo? lastCheckpoint))
if (!this._checkpointManager.TryGetLastCheckpoint(this._sessionId, out CheckpointInfo? lastCheckpoint))
{
throw new InvalidOperationException("No checkpoints available to resume from.");
}
@@ -1,6 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Reflection;
@@ -134,7 +137,7 @@ public abstract class StatefulExecutor<TState> : Executor
}
/// <inheritdoc cref="IResettableExecutor.ResetAsync"/>
protected ValueTask ResetAsync()
protected virtual ValueTask ResetAsync()
{
this._stateCache = this._initialStateFactory();
@@ -151,13 +154,25 @@ public abstract class StatefulExecutor<TState> : Executor
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="initialStateFactory">A factory to initialize the state value to be used by the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="sentMessageTypes">Message types sent by the handler. Defaults to empty, and will filter out non-matching messages.</param>
/// <param name="outputTypes">Message types yielded as output by the handler. Defaults to empty.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public abstract class StatefulExecutor<TState, TInput>(string id, Func<TState> initialStateFactory, StatefulExecutorOptions? options = null, bool declareCrossRunShareable = false)
public abstract class StatefulExecutor<TState, TInput>(string id,
Func<TState> initialStateFactory,
StatefulExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false)
: StatefulExecutor<TState>(id, initialStateFactory, options, declareCrossRunShareable), IMessageHandler<TInput>
{
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TInput>(this.HandleAsync);
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
protocolBuilder.RouteBuilder.AddHandler<TInput>(this.HandleAsync);
return protocolBuilder.SendsMessageTypes(sentMessageTypes ?? [])
.YieldsOutputTypes(outputTypes ?? []);
}
/// <inheritdoc/>
public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default);
@@ -173,13 +188,35 @@ public abstract class StatefulExecutor<TState, TInput>(string id, Func<TState> i
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="initialStateFactory">A factory to initialize the state value to be used by the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="sentMessageTypes">Message types sent by the handler. Defaults to empty, and will filter out non-matching messages.</param>
/// <param name="outputTypes">Message types yielded as output by the handler. Defaults to empty.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public abstract class StatefulExecutor<TState, TInput, TOutput>(string id, Func<TState> initialStateFactory, StatefulExecutorOptions? options = null, bool declareCrossRunShareable = false)
public abstract class StatefulExecutor<TState, TInput, TOutput>(string id,
Func<TState> initialStateFactory,
StatefulExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false)
: StatefulExecutor<TState>(id, initialStateFactory, options, declareCrossRunShareable), IMessageHandler<TInput, TOutput>
where TOutput : notnull
{
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TInput, TOutput>(this.HandleAsync);
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
protocolBuilder.RouteBuilder.AddHandler<TInput, TOutput>(this.HandleAsync);
if (this.Options.AutoSendMessageHandlerResultObject)
{
protocolBuilder.SendsMessage<TOutput>();
}
if (this.Options.AutoYieldOutputHandlerResultObject)
{
protocolBuilder.YieldsOutput<TOutput>();
}
return protocolBuilder.SendsMessageTypes(sentMessageTypes ?? []).YieldsOutputTypes(outputTypes ?? []);
}
/// <inheritdoc/>
public abstract ValueTask<TOutput> HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default);
@@ -14,19 +14,19 @@ namespace Microsoft.Agents.AI.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 sealed class StreamingRun : IAsyncDisposable
public sealed class StreamingRun : CheckpointableRunBase, IAsyncDisposable
{
private readonly AsyncRunHandle _runHandle;
internal StreamingRun(AsyncRunHandle runHandle)
internal StreamingRun(AsyncRunHandle runHandle) : base(runHandle)
{
this._runHandle = Throw.IfNull(runHandle);
}
/// <summary>
/// A unique identifier for the run. Can be provided at the start of the run, or auto-generated.
/// A unique identifier for the session. Can be provided at the start of the session, or auto-generated.
/// </summary>
public string RunId => this._runHandle.RunId;
public string SessionId => this._runHandle.SessionId;
/// <summary>
/// Gets the current execution status of the workflow run.
@@ -27,9 +27,11 @@ public record SubworkflowBinding(Workflow WorkflowInstance, string Id, ExecutorO
return InitHostExecutorAsync;
ValueTask<Executor> InitHostExecutorAsync(string runId)
async ValueTask<Executor> InitHostExecutorAsync(string sessionId)
{
return new(new WorkflowHostExecutor(id, workflow, runId, ownershipToken, options));
ProtocolDescriptor workflowProtocol = await workflow.DescribeProtocolAsync().ConfigureAwait(false);
return new WorkflowHostExecutor(id, workflow, workflowProtocol, sessionId, ownershipToken, options);
}
}
@@ -84,9 +84,9 @@ public sealed class SwitchBuilder
List<(Func<object?, bool> Predicate, HashSet<int> OutgoingIndicies)> caseMap = this._caseMap;
HashSet<int> defaultIndicies = this._defaultIndicies;
return builder.AddFanOutEdge<object>(source, this._executors, CasePartitioner);
return builder.AddFanOutEdge<object>(source, this._executors, EdgeSelector);
IEnumerable<int> CasePartitioner(object? input, int targetCount)
IEnumerable<int> EdgeSelector(object? input, int targetCount)
{
Debug.Assert(targetCount == this._executors.Count);
@@ -153,18 +153,52 @@ public static class WorkflowVisualizer
private static void EmitWorkflowMermaid(Workflow workflow, List<string> lines, string indent, string? ns = null)
{
string MapId(string id) => ns != null ? $"{ns}/{id}" : id;
// Build a mapping from raw IDs to Mermaid-safe node aliases that preserve
// as much of the original ID as possible for readability.
// Mermaid node IDs cannot contain spaces, dots, pipes, or most special characters.
var aliasMap = new Dictionary<string, string>();
var usedAliases = new HashSet<string>(StringComparer.Ordinal);
string GetSafeId(string id)
{
var key = ns != null ? $"{ns}/{id}" : id;
if (!aliasMap.TryGetValue(key, out var alias))
{
alias = SanitizeMermaidNodeId(key);
// Handle collisions by appending a numeric suffix
if (!usedAliases.Add(alias))
{
var i = 2;
while (!usedAliases.Add($"{alias}_{i}"))
{
if (i >= 10_000)
{
throw new InvalidOperationException($"Unable to generate a unique Mermaid node ID for '{key}'.");
}
i++;
}
alias = $"{alias}_{i}";
}
aliasMap[key] = alias;
}
return alias;
}
// Add start node
var startExecutorId = workflow.StartExecutorId;
lines.Add($"{indent}{MapId(startExecutorId)}[\"{startExecutorId} (Start)\"];");
lines.Add($"{indent}{GetSafeId(startExecutorId)}[\"{EscapeMermaidLabel(startExecutorId)} (Start)\"];");
// Add other executor nodes
foreach (var executorId in workflow.ExecutorBindings.Keys)
{
if (executorId != startExecutorId)
{
lines.Add($"{indent}{MapId(executorId)}[\"{executorId}\"];");
lines.Add($"{indent}{GetSafeId(executorId)}[\"{EscapeMermaidLabel(executorId)}\"];");
}
}
@@ -175,7 +209,7 @@ public static class WorkflowVisualizer
lines.Add("");
foreach (var (nodeId, _, _) in fanInDescriptors)
{
lines.Add($"{indent}{MapId(nodeId)}((fan-in))");
lines.Add($"{indent}{GetSafeId(nodeId)}((fan-in))");
}
}
@@ -184,9 +218,9 @@ public static class WorkflowVisualizer
{
foreach (var src in sources)
{
lines.Add($"{indent}{MapId(src)} --> {MapId(nodeId)};");
lines.Add($"{indent}{GetSafeId(src)} --> {GetSafeId(nodeId)};");
}
lines.Add($"{indent}{MapId(nodeId)} --> {MapId(target)};");
lines.Add($"{indent}{GetSafeId(nodeId)} --> {GetSafeId(target)};");
}
// Emit normal edges
@@ -197,17 +231,17 @@ public static class WorkflowVisualizer
string effectiveLabel = label != null ? EscapeMermaidLabel(label) : "conditional";
// Conditional edge, with user label or default
lines.Add($"{indent}{MapId(src)} -. {effectiveLabel} .--> {MapId(target)};");
lines.Add($"{indent}{GetSafeId(src)} -. {effectiveLabel} .-> {GetSafeId(target)};");
}
else if (label != null)
{
// Regular edge with label
lines.Add($"{indent}{MapId(src)} -->|{EscapeMermaidLabel(label)}| {MapId(target)};");
lines.Add($"{indent}{GetSafeId(src)} -->|{EscapeMermaidLabel(label)}| {GetSafeId(target)};");
}
else
{
// Regular edge without label
lines.Add($"{indent}{MapId(src)} --> {MapId(target)};");
lines.Add($"{indent}{GetSafeId(src)} --> {GetSafeId(target)};");
}
}
}
@@ -301,6 +335,50 @@ public static class WorkflowVisualizer
return false;
}
/// <summary>
/// Converts a raw node ID into a Mermaid-safe identifier that preserves as much
/// of the original text as possible. ASCII letters, digits, and underscores are kept
/// as-is (including existing consecutive underscores). All other characters (including
/// non-ASCII letters) are replaced with underscores, with consecutive invalid characters
/// collapsed into a single underscore. A leading digit gets a prefix.
/// </summary>
private static string SanitizeMermaidNodeId(string id)
{
Throw.IfNull(id);
var sb = new StringBuilder(id.Length);
bool lastWasUnderscore = false;
foreach (var ch in id)
{
bool isAsciiSafe = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_';
if (isAsciiSafe)
{
sb.Append(ch);
lastWasUnderscore = ch == '_';
}
else if (!lastWasUnderscore)
{
sb.Append('_');
lastWasUnderscore = true;
}
}
// Trim trailing underscore
while (sb.Length > 0 && sb[sb.Length - 1] == '_')
{
sb.Length--;
}
// Mermaid IDs must not start with a digit
if (sb.Length > 0 && sb[0] >= '0' && sb[0] <= '9')
{
sb.Insert(0, "n_");
}
// Guard against empty result (e.g. id was all special chars)
return sb.Length == 0 ? "node" : sb.ToString();
}
// Helper method to escape special characters in DOT labels
private static string EscapeDotLabel(string label)
{
@@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Agents.AI.Workflows.Observability;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
@@ -76,6 +77,11 @@ public class Workflow
/// </summary>
public string? Description { get; internal init; }
/// <summary>
/// Gets the telemetry context for the workflow.
/// </summary>
internal WorkflowTelemetryContext TelemetryContext { get; }
internal bool AllowConcurrent => this.ExecutorBindings.Values.All(registration => registration.SupportsConcurrentSharedExecution);
internal IEnumerable<string> NonConcurrentExecutorIds =>
@@ -88,11 +94,13 @@ public class Workflow
/// <param name="startExecutorId">The unique identifier of the starting executor for the workflow. Cannot be <c>null</c>.</param>
/// <param name="name">Optional human-readable name for the workflow.</param>
/// <param name="description">Optional description of what the workflow does.</param>
internal Workflow(string startExecutorId, string? name = null, string? description = null)
/// <param name="telemetryContext">Optional telemetry context for the workflow.</param>
internal Workflow(string startExecutorId, string? name = null, string? description = null, WorkflowTelemetryContext? telemetryContext = null)
{
this.StartExecutorId = Throw.IfNull(startExecutorId);
this.Name = name;
this.Description = description;
this.TelemetryContext = telemetryContext ?? WorkflowTelemetryContext.Disabled;
}
private bool _needsReset;
@@ -210,8 +218,14 @@ public class Workflow
ExecutorBinding startExecutorRegistration = this.ExecutorBindings[this.StartExecutorId];
Executor startExecutor = await startExecutorRegistration.CreateInstanceAsync(string.Empty)
.ConfigureAwait(false);
startExecutor.Configure(new NoOpExternalRequestContext());
startExecutor.AttachRequestContext(new NoOpExternalRequestContext());
return startExecutor.DescribeProtocol();
ProtocolDescriptor inputProtocol = startExecutor.DescribeProtocol();
IEnumerable<Task<Executor>> outputExecutorTasks = this.OutputExecutors.Select(executorId => this.ExecutorBindings[executorId].CreateInstanceAsync(string.Empty).AsTask());
Executor[] outputExecutors = await Task.WhenAll(outputExecutorTasks).ConfigureAwait(false);
IEnumerable<Type> yieldedTypes = outputExecutors.SelectMany(executor => executor.DescribeProtocol().Yields);
return new(inputProtocol.Accepts, yieldedTypes, [], inputProtocol.AcceptsAll);
}
}
@@ -38,9 +38,7 @@ public class WorkflowBuilder
private readonly string _startExecutorId;
private string? _name;
private string? _description;
private static readonly string s_namespace = typeof(WorkflowBuilder).Namespace!;
private static readonly ActivitySource s_activitySource = new(s_namespace);
private WorkflowTelemetryContext _telemetryContext = WorkflowTelemetryContext.Disabled;
/// <summary>
/// Initializes a new instance of the WorkflowBuilder class with the specified starting executor.
@@ -137,6 +135,15 @@ public class WorkflowBuilder
return this;
}
/// <summary>
/// Sets the telemetry context for the workflow.
/// </summary>
/// <param name="context">The telemetry context to use.</param>
internal void SetTelemetryContext(WorkflowTelemetryContext context)
{
this._telemetryContext = Throw.IfNull(context);
}
/// <summary>
/// Binds the specified executor (via registration) to the workflow, allowing it to participate in workflow execution.
/// </summary>
@@ -415,30 +422,26 @@ public class WorkflowBuilder
}
/// <summary>
/// Adds a fan-in edge to the workflow, connecting multiple source executors to a single target executor with an
/// optional trigger condition.
/// Adds a fan-in "barrier" edge to the workflow, connecting multiple source executors to a single target executor. Messages
/// will be held until every source executor has generated at least one message, then they will be streamed to the target
/// executor in the following step.
/// </summary>
/// <remarks>This method establishes a fan-in relationship, allowing the target executor to be activated
/// based on the completion or state of multiple sources. The trigger parameter can be used to customize activation
/// behavior.</remarks>
/// <param name="sources">One or more source executors that provide input to the target. Cannot be null or empty.</param>
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanInEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target)
=> this.AddFanInEdge(sources, target, label: null);
public WorkflowBuilder AddFanInBarrierEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target)
=> this.AddFanInBarrierEdge(sources, target, label: null);
/// <summary>
/// Adds a fan-in edge to the workflow, connecting multiple source executors to a single target executor with an
/// optional trigger condition.
/// Adds a fan-in "barrier" edge to the workflow, connecting multiple source executors to a single target executor. Messages
/// will be held until every source executor has generated at least one message, then they will be streamed to the target
/// executor in the following step.
/// </summary>
/// <remarks>This method establishes a fan-in relationship, allowing the target executor to be activated
/// based on the completion or state of multiple sources. The trigger parameter can be used to customize activation
/// behavior.</remarks>
/// <param name="sources">One or more source executors that provide input to the target. Cannot be null or empty.</param>
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanInEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target, string? label = null)
public WorkflowBuilder AddFanInBarrierEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target, string? label = null)
{
Throw.IfNull(target);
Throw.IfNull(sources);
@@ -465,10 +468,10 @@ public class WorkflowBuilder
return this;
}
/// <inheritdoc cref="AddFanInEdge(IEnumerable{ExecutorBinding}, ExecutorBinding)"/>
[Obsolete("Use AddFanInEdge(IEnumerable<ExecutorBinding>, ExecutorBinding) instead.")]
public WorkflowBuilder AddFanInEdge(ExecutorBinding target, params IEnumerable<ExecutorBinding> sources)
=> this.AddFanInEdge(sources, target);
/// <inheritdoc cref="AddFanInBarrierEdge(IEnumerable{ExecutorBinding}, ExecutorBinding)"/>
[Obsolete("Use AddFanInBarrierEdge(IEnumerable<ExecutorBinding>, ExecutorBinding) instead.")]
public WorkflowBuilder AddFanInBarrierEdge(ExecutorBinding target, params IEnumerable<ExecutorBinding> sources)
=> this.AddFanInBarrierEdge(sources, target);
private void Validate(bool validateOrphans)
{
@@ -563,7 +566,7 @@ public class WorkflowBuilder
activity?.AddEvent(new ActivityEvent(EventNames.BuildValidationCompleted));
var workflow = new Workflow(this._startExecutorId, this._name, this._description)
var workflow = new Workflow(this._startExecutorId, this._name, this._description, this._telemetryContext)
{
ExecutorBindings = this._executorBindings,
Edges = this._edges,
@@ -601,7 +604,7 @@ public class WorkflowBuilder
/// or if the start executor is not bound.</exception>
public Workflow Build(bool validateOrphans = true)
{
using Activity? activity = s_activitySource.StartActivity(ActivityNames.WorkflowBuild);
using Activity? activity = this._telemetryContext.StartWorkflowBuildActivity();
var workflow = this.BuildInternal(validateOrphans, activity);
@@ -6,79 +6,65 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider
{
private int _bookmark;
private readonly List<ChatMessage> _chatMessages = [];
private readonly ProviderSessionState<StoreState> _sessionState;
private IReadOnlyList<string>? _stateKeys;
public WorkflowChatHistoryProvider()
/// <summary>
/// Initializes a new instance of the <see cref="WorkflowChatHistoryProvider"/> class.
/// </summary>
/// <param name="jsonSerializerOptions">
/// Optional JSON serializer options for serializing the state of this provider.
/// This is valuable for cases like when the chat history contains custom <see cref="AIContent"/> types
/// and source generated serializers are required, or Native AOT / Trimming is required.
/// </param>
public WorkflowChatHistoryProvider(JsonSerializerOptions? jsonSerializerOptions = null)
{
this._sessionState = new ProviderSessionState<StoreState>(
_ => new StoreState(),
this.GetType().Name,
jsonSerializerOptions);
}
public WorkflowChatHistoryProvider(StoreState state)
{
this.ImportStoreState(Throw.IfNull(state));
}
private void ImportStoreState(StoreState state, bool clearMessages = false)
{
if (clearMessages)
{
this._chatMessages.Clear();
}
if (state?.Messages is not null)
{
this._chatMessages.AddRange(state.Messages);
}
this._bookmark = state?.Bookmark ?? 0;
}
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
internal sealed class StoreState
{
public int Bookmark { get; set; }
public IList<ChatMessage> Messages { get; set; } = [];
public List<ChatMessage> Messages { get; set; } = [];
}
internal void AddMessages(params IEnumerable<ChatMessage> messages) => this._chatMessages.AddRange(messages);
internal void AddMessages(AgentSession session, params IEnumerable<ChatMessage> messages)
=> this._sessionState.GetOrInitializeState(session).Messages.AddRange(messages);
public override ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> new(this._chatMessages.AsReadOnly());
protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> new(this._sessionState.GetOrInitializeState(context.Session).Messages);
public override ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
if (context.InvokeException is not null)
{
return default;
}
var allNewMessages = context.RequestMessages.Concat(context.AIContextProviderMessages ?? []).Concat(context.ResponseMessages ?? []);
this._chatMessages.AddRange(allNewMessages);
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
this._sessionState.GetOrInitializeState(context.Session).Messages.AddRange(allNewMessages);
return default;
}
public IEnumerable<ChatMessage> GetFromBookmark()
public IEnumerable<ChatMessage> GetFromBookmark(AgentSession session)
{
for (int i = this._bookmark; i < this._chatMessages.Count; i++)
var state = this._sessionState.GetOrInitializeState(session);
for (int i = state.Bookmark; i < state.Messages.Count; i++)
{
yield return this._chatMessages[i];
yield return state.Messages[i];
}
}
public void UpdateBookmark() => this._bookmark = this._chatMessages.Count;
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
public void UpdateBookmark(AgentSession session)
{
StoreState state = this.ExportStoreState();
return JsonSerializer.SerializeToElement(state,
WorkflowsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(StoreState)));
var state = this._sessionState.GetOrInitializeState(session);
state.Bookmark = state.Messages.Count;
}
internal StoreState ExportStoreState() => new() { Bookmark = this._bookmark, Messages = this._chatMessages };
}
@@ -7,6 +7,7 @@ using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -16,22 +17,29 @@ internal sealed class WorkflowHostAgent : AIAgent
{
private readonly Workflow _workflow;
private readonly string? _id;
private readonly CheckpointManager? _checkpointManager;
private readonly IWorkflowExecutionEnvironment _executionEnvironment;
private readonly bool _includeExceptionDetails;
private readonly bool _includeWorkflowOutputsInResponse;
private readonly Task<ProtocolDescriptor> _describeTask;
private readonly ConcurrentDictionary<string, string> _assignedRunIds = [];
private readonly ConcurrentDictionary<string, string> _assignedSessionIds = [];
public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = null, string? description = null, CheckpointManager? checkpointManager = null, IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false)
public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = null, string? description = null, IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false)
{
this._workflow = Throw.IfNull(workflow);
this._executionEnvironment = executionEnvironment ?? (workflow.AllowConcurrent
? InProcessExecution.Concurrent
: InProcessExecution.OffThread);
this._checkpointManager = checkpointManager;
if (!this._executionEnvironment.IsCheckpointingEnabled &&
this._executionEnvironment is not InProcessExecutionEnvironment)
{
// Cannot have an implicit CheckpointManager for non-InProcessExecution environments (or others that
// support BYO Checkpointing.
throw new InvalidOperationException("Cannot use a non-checkpointed execution environment. Implicit checkpointing is supported only for InProcess.");
}
this._includeExceptionDetails = includeExceptionDetails;
this._includeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse;
@@ -54,7 +62,7 @@ internal sealed class WorkflowHostAgent : AIAgent
do
{
result = Guid.NewGuid().ToString("N");
} while (!this._assignedRunIds.TryAdd(result, result));
} while (!this._assignedSessionIds.TryAdd(result, result));
return result;
}
@@ -65,15 +73,27 @@ internal sealed class WorkflowHostAgent : AIAgent
protocol.ThrowIfNotChatProtocol(allowCatchAll: true);
}
public override ValueTask<AgentSession> GetNewSessionAsync(CancellationToken cancellationToken = default)
=> new(new WorkflowSession(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails, this._includeWorkflowOutputsInResponse));
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new WorkflowSession(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._includeExceptionDetails, this._includeWorkflowOutputsInResponse));
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new WorkflowSession(this._workflow, serializedSession, this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails, this._includeWorkflowOutputsInResponse, jsonSerializerOptions));
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(session);
if (session is not WorkflowSession workflowSession)
{
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(WorkflowSession)}' can be serialized by this agent.");
}
return new(workflowSession.Serialize(jsonSerializerOptions));
}
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new WorkflowSession(this._workflow, serializedState, this._executionEnvironment, this._includeExceptionDetails, this._includeWorkflowOutputsInResponse, jsonSerializerOptions));
private async ValueTask<WorkflowSession> UpdateSessionAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, CancellationToken cancellationToken = default)
{
session ??= await this.GetNewSessionAsync(cancellationToken).ConfigureAwait(false);
session ??= await this.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
if (session is not WorkflowSession workflowSession)
{
@@ -82,7 +102,7 @@ internal sealed class WorkflowHostAgent : AIAgent
// For workflow threads, messages are added directly via the internal AddMessages method
// The MessageStore methods are used for agent invocation scenarios
workflowSession.ChatHistoryProvider.AddMessages(messages);
workflowSession.ChatHistoryProvider.AddMessages(session, messages);
return workflowSession;
}
@@ -17,7 +17,6 @@ public static class WorkflowHostingExtensions
/// <param name="id">A unique id for the hosting <see cref="AIAgent"/>.</param>
/// <param name="name">A name for the hosting <see cref="AIAgent"/>.</param>
/// <param name="description">A description for the hosting <see cref="AIAgent"/>.</param>
/// <param name="checkpointManager">A <see cref="CheckpointManager"/> to enable persistence of run state.</param>
/// <param name="executionEnvironment">Specify the execution environment to use when running the workflows. See
/// <see cref="InProcessExecution.OffThread"/>, <see cref="InProcessExecution.Concurrent"/> and
/// <see cref="InProcessExecution.Lockstep"/> for the in-process environments.</param>
@@ -26,17 +25,16 @@ public static class WorkflowHostingExtensions
/// <param name="includeWorkflowOutputsInResponse">If <see langword="true"/>, will transform outgoing workflow outputs
/// into into content in <see cref="AgentResponseUpdate"/>s or the <see cref="AgentResponse"/> as appropriate.</param>
/// <returns></returns>
public static AIAgent AsAgent(
public static AIAgent AsAIAgent(
this Workflow workflow,
string? id = null,
string? name = null,
string? description = null,
CheckpointManager? checkpointManager = null,
IWorkflowExecutionEnvironment? executionEnvironment = null,
bool includeExceptionDetails = false,
bool includeWorkflowOutputsInResponse = false)
{
return new WorkflowHostAgent(workflow, id, name, description, checkpointManager, executionEnvironment, includeExceptionDetails, includeWorkflowOutputsInResponse);
return new WorkflowHostAgent(workflow, id, name, description, executionEnvironment, includeExceptionDetails, includeWorkflowOutputsInResponse);
}
internal static FunctionCallContent ToFunctionCall(this ExternalRequest request)
@@ -2,23 +2,37 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Event triggered when a workflow executor yields output.
/// </summary>
public sealed class WorkflowOutputEvent : WorkflowEvent
[JsonDerivedType(typeof(AgentResponseEvent))]
[JsonDerivedType(typeof(AgentResponseUpdateEvent))]
public class WorkflowOutputEvent : WorkflowEvent
{
internal WorkflowOutputEvent(object data, string sourceId) : base(data)
/// <summary>
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class.
/// </summary>
/// <param name="data">The output data.</param>
/// <param name="executorId">The identifier of the executor that yielded this output.</param>
public WorkflowOutputEvent(object data, string executorId) : base(data)
{
this.SourceId = sourceId;
this.ExecutorId = executorId;
}
/// <summary>
/// The unique identifier of the executor that yielded this output.
/// </summary>
public string SourceId { get; }
public string ExecutorId { get; }
/// <summary>
/// The unique identifier of the executor that yielded this output.
/// </summary>
[Obsolete("Use ExecutorId instead.")]
public string SourceId => this.ExecutorId;
/// <summary>
/// Determines whether the underlying data is of the specified type or a derived type.
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
@@ -9,6 +10,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -21,26 +23,48 @@ internal sealed class WorkflowSession : AgentSession
private readonly bool _includeExceptionDetails;
private readonly bool _includeWorkflowOutputsInResponse;
private readonly CheckpointManager _checkpointManager;
private readonly InMemoryCheckpointManager? _inMemoryCheckpointManager;
private InMemoryCheckpointManager? _inMemoryCheckpointManager;
public WorkflowSession(Workflow workflow, string runId, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false)
internal static bool VerifyCheckpointingConfiguration(IWorkflowExecutionEnvironment executionEnvironment, [NotNullWhen(true)] out InProcessExecutionEnvironment? inProcEnv)
{
inProcEnv = null;
if (executionEnvironment.IsCheckpointingEnabled)
{
return false;
}
if ((inProcEnv = executionEnvironment as InProcessExecutionEnvironment) == null)
{
throw new InvalidOperationException("Cannot use a non-checkpointed execution environment. Implicit checkpointing is supported only for InProcess.");
}
return true;
}
public WorkflowSession(Workflow workflow, string sessionId, IWorkflowExecutionEnvironment executionEnvironment, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false)
{
this._workflow = Throw.IfNull(workflow);
this._executionEnvironment = Throw.IfNull(executionEnvironment);
this._includeExceptionDetails = includeExceptionDetails;
this._includeWorkflowOutputsInResponse = includeWorkflowOutputsInResponse;
// If the user provided an external checkpoint manager, use that, otherwise rely on an in-memory one.
// TODO: Implement persist-only-last functionality for in-memory checkpoint manager, to avoid unbounded
// memory growth.
this._checkpointManager = checkpointManager ?? new(this._inMemoryCheckpointManager = new());
if (VerifyCheckpointingConfiguration(executionEnvironment, out InProcessExecutionEnvironment? inProcEnv))
{
// We have an InProcessExecutionEnvironment which is not configured for checkpointing. Ensure it has an externalizable checkpoint manager,
// since we are responsible for maintaining the state.
this._executionEnvironment = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing());
}
this.RunId = Throw.IfNullOrEmpty(runId);
this.SessionId = Throw.IfNullOrEmpty(sessionId);
this.ChatHistoryProvider = new WorkflowChatHistoryProvider();
}
public WorkflowSession(Workflow workflow, JsonElement serializedSession, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, JsonSerializerOptions? jsonSerializerOptions = null)
private CheckpointManager EnsureExternalizedInMemoryCheckpointing()
{
return new(this._inMemoryCheckpointManager ??= new());
}
public WorkflowSession(Workflow workflow, JsonElement serializedSession, IWorkflowExecutionEnvironment executionEnvironment, bool includeExceptionDetails = false, bool includeWorkflowOutputsInResponse = false, JsonSerializerOptions? jsonSerializerOptions = null)
{
this._workflow = Throw.IfNull(workflow);
this._executionEnvironment = Throw.IfNull(executionEnvironment);
@@ -51,38 +75,33 @@ internal sealed class WorkflowSession : AgentSession
SessionState sessionState = marshaller.Marshal<SessionState>(serializedSession);
this._inMemoryCheckpointManager = sessionState.CheckpointManager;
if (this._inMemoryCheckpointManager is not null && checkpointManager is not null)
if (this._inMemoryCheckpointManager != null &&
VerifyCheckpointingConfiguration(executionEnvironment, out InProcessExecutionEnvironment? inProcEnv))
{
// The session was externalized with an in-memory checkpoint manager, but the caller is providing an external one.
throw new ArgumentException("Cannot provide an external checkpoint manager when deserializing a session that " +
"was serialized with an in-memory checkpoint manager.", nameof(checkpointManager));
this._executionEnvironment = inProcEnv.WithCheckpointing(this.EnsureExternalizedInMemoryCheckpointing());
}
else if (this._inMemoryCheckpointManager is null && checkpointManager is null)
else if (this._inMemoryCheckpointManager != null)
{
// The session was externalized without an in-memory checkpoint manager, and the caller is not providing an external one.
throw new ArgumentException("An external checkpoint manager must be provided when deserializing a session that " +
"was serialized without an in-memory checkpoint manager.", nameof(checkpointManager));
}
else
{
this._checkpointManager = checkpointManager ?? new(this._inMemoryCheckpointManager!);
throw new ArgumentException("The session was saved with an externalized checkpoint manager, but the incoming execution environment does not support it.", nameof(executionEnvironment));
}
this.RunId = sessionState.RunId;
this.SessionId = sessionState.SessionId;
this.ChatHistoryProvider = new WorkflowChatHistoryProvider();
this.LastCheckpoint = sessionState.LastCheckpoint;
this.ChatHistoryProvider = new WorkflowChatHistoryProvider(sessionState.ChatHistoryProviderState);
this.StateBag = sessionState.StateBag;
}
public CheckpointInfo? LastCheckpoint { get; set; }
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
JsonMarshaller marshaller = new(jsonSerializerOptions);
SessionState info = new(
this.RunId,
this.SessionId,
this.LastCheckpoint,
this.ChatHistoryProvider.ExportStoreState(),
this._inMemoryCheckpointManager);
this._inMemoryCheckpointManager,
this.StateBag);
return marshaller.Marshal(info);
}
@@ -100,7 +119,7 @@ internal sealed class WorkflowSession : AgentSession
RawRepresentation = raw
};
this.ChatHistoryProvider.AddMessages(update.ToChatMessage());
this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage());
return update;
}
@@ -117,34 +136,32 @@ internal sealed class WorkflowSession : AgentSession
RawRepresentation = raw
};
this.ChatHistoryProvider.AddMessages(update.ToChatMessage());
this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage());
return update;
}
private async ValueTask<Checkpointed<StreamingRun>> CreateOrResumeRunAsync(List<ChatMessage> messages, CancellationToken cancellationToken = default)
private async ValueTask<StreamingRun> CreateOrResumeRunAsync(List<ChatMessage> messages, CancellationToken cancellationToken = default)
{
// The workflow is validated to be a ChatProtocol workflow by the WorkflowHostAgent before creating the session,
// and does not need to be checked again here.
if (this.LastCheckpoint is not null)
{
Checkpointed<StreamingRun> checkpointed =
StreamingRun run =
await this._executionEnvironment
.ResumeStreamAsync(this._workflow,
.ResumeStreamingAsync(this._workflow,
this.LastCheckpoint,
this._checkpointManager,
cancellationToken)
.ConfigureAwait(false);
await checkpointed.Run.TrySendMessageAsync(messages).ConfigureAwait(false);
return checkpointed;
await run.TrySendMessageAsync(messages).ConfigureAwait(false);
return run;
}
return await this._executionEnvironment
.StreamAsync(this._workflow,
.RunStreamingAsync(this._workflow,
messages,
this._checkpointManager,
this.RunId,
this.SessionId,
cancellationToken)
.ConfigureAwait(false);
}
@@ -156,14 +173,13 @@ internal sealed class WorkflowSession : AgentSession
try
{
this.LastResponseId = Guid.NewGuid().ToString("N");
List<ChatMessage> messages = this.ChatHistoryProvider.GetFromBookmark().ToList();
List<ChatMessage> messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList();
#pragma warning disable CA2007 // Analyzer misfiring and not seeing .ConfigureAwait(false) below.
await using Checkpointed<StreamingRun> checkpointed =
await using StreamingRun run =
await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false);
#pragma warning restore CA2007
StreamingRun run = checkpointed.Run;
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken)
.ConfigureAwait(false)
@@ -240,26 +256,26 @@ internal sealed class WorkflowSession : AgentSession
finally
{
// Do we want to try to undo the step, and not update the bookmark?
this.ChatHistoryProvider.UpdateBookmark();
this.ChatHistoryProvider.UpdateBookmark(this);
}
}
public string? LastResponseId { get; set; }
public string RunId { get; }
public string SessionId { get; }
/// <inheritdoc/>
public WorkflowChatHistoryProvider ChatHistoryProvider { get; }
internal sealed class SessionState(
string runId,
string sessionId,
CheckpointInfo? lastCheckpoint,
WorkflowChatHistoryProvider.StoreState chatHistoryProviderState,
InMemoryCheckpointManager? checkpointManager = null)
InMemoryCheckpointManager? checkpointManager = null,
AgentSessionStateBag? stateBag = null)
{
public string RunId { get; } = runId;
public string SessionId { get; } = sessionId;
public CheckpointInfo? LastCheckpoint { get; } = lastCheckpoint;
public WorkflowChatHistoryProvider.StoreState ChatHistoryProviderState { get; } = chatHistoryProviderState;
public InMemoryCheckpointManager? CheckpointManager { get; } = checkpointManager;
public AgentSessionStateBag StateBag { get; } = stateBag ?? new();
}
}