mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge main into feat/durable_task and resolve conflicts
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
+10
-10
@@ -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);
|
||||
}
|
||||
|
||||
+19
-16
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user