Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs
T
Peter Ibekwe 38de991481 .NET: Fix RequestInfoEvent lost when resuming workflow from checkpoint (#4955)
* Fix RequestInfoEvent lost when resuming workflow from checkpoint

* Fix streaming run double disposal in tests and lockstep republishing before Started event is emitted.

* Fix bug to remove messages after sending to avoid losing messages on send failure.

* Fix declarative test harness
2026-04-01 15:38:48 +00:00

171 lines
6.9 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class RequestPortOptions;
internal sealed class RequestInfoExecutor : Executor
{
private const string WrappedRequestsStateKey = nameof(WrappedRequestsStateKey);
private readonly Dictionary<string, ExternalRequest> _wrappedRequests = [];
private RequestPort Port { get; }
private IExternalRequestSink? RequestSink { get; set; }
private static ExecutorOptions DefaultOptions => new()
{
// We need to be able to return the ExternalRequest/Result objects so they can be bubbled up
// through the event system, but we do not want to forward the Request message.
AutoSendMessageHandlerResultObject = false,
AutoYieldOutputHandlerResultObject = false
};
private readonly bool _allowWrapped;
public RequestInfoExecutor(RequestPort port, bool allowWrapped = true) : base(port.Id, DefaultOptions)
{
this.Port = port;
this._allowWrapped = allowWrapped;
}
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return protocolBuilder.ConfigureRoutes(ConfigureRoutes)
.SendsMessage<ExternalRequest>()
.SendsMessageType(this.Port.Response);
void ConfigureRoutes(RouteBuilder routeBuilder)
{
routeBuilder = routeBuilder
// Handle incoming requests (as raw request payloads)
.AddHandlerUntyped(this.Port.Request, this.HandleAsync)
.AddCatchAll(this.HandleCatchAllAsync);
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);
public async ValueTask<ExternalRequest?> HandleCatchAllAsync(PortableValue message, IWorkflowContext context, CancellationToken cancellationToken)
{
Throw.IfNull(message);
object? maybeRequest = message.AsType(this.Port.Request);
if (maybeRequest != null)
{
Debug.Assert(this.Port.Request.IsInstanceOfType(maybeRequest));
ExternalRequest request = ExternalRequest.Create(this.Port, maybeRequest!);
await this.RequestSink!.PostAsync(request).ConfigureAwait(false);
return request;
}
else if (message.Is(out ExternalRequest? request))
{
return await this.HandleAsync(request, context, cancellationToken).ConfigureAwait(false);
}
return null;
}
public async ValueTask<ExternalRequest> HandleAsync(ExternalRequest message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Debug.Assert(this._allowWrapped);
Throw.IfNull(message);
if (!message.Data.IsType(this.Port.Request, out var requestData))
{
throw new InvalidOperationException($"Message type {message.Data.TypeId} could not be interpreted as a value of Request Type {this.Port.Request}");
}
if (!message.PortInfo.ResponseType.IsMatchPolymorphic(this.Port.Response))
{
throw new InvalidOperationException($"Response type {this.Port.Response} is not a valid response for original request, whose expected response is {message.PortInfo.ResponseType}");
}
ExternalRequest request = ExternalRequest.Create(this.Port, requestData, message.RequestId);
this._wrappedRequests.Add(message.RequestId, message);
await this.RequestSink!.PostAsync(request).ConfigureAwait(false);
return request;
}
public async ValueTask<ExternalRequest> HandleAsync(object message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Throw.IfNull(message);
Debug.Assert(this.Port.Request.IsInstanceOfType(message));
ExternalRequest request = ExternalRequest.Create(this.Port, message);
await this.RequestSink!.PostAsync(request).ConfigureAwait(false);
return request;
}
public async ValueTask<ExternalResponse?> HandleAsync(ExternalResponse message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (!this.Port.IsResponsePort(message))
{
return null;
}
if (!message.Data.IsType(this.Port.Response, out object? data))
{
throw this.Port.CreateExceptionForType(message);
}
if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest))
{
await context.SendMessageAsync(originalRequest.RewrapResponse(message), cancellationToken: cancellationToken).ConfigureAwait(false);
this._wrappedRequests.Remove(message.RequestId);
}
else
{
await context.SendMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(data, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return message;
}
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await context.QueueStateUpdateAsync(WrappedRequestsStateKey,
new Dictionary<string, ExternalRequest>(this._wrappedRequests, StringComparer.Ordinal),
cancellationToken: cancellationToken).ConfigureAwait(false);
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
}
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
this._wrappedRequests.Clear();
Dictionary<string, ExternalRequest> wrappedRequests =
await context.ReadStateAsync<Dictionary<string, ExternalRequest>>(WrappedRequestsStateKey, cancellationToken: cancellationToken)
.ConfigureAwait(false) ?? [];
foreach (KeyValuePair<string, ExternalRequest> wrappedRequest in wrappedRequests)
{
this._wrappedRequests[wrappedRequest.Key] = wrappedRequest.Value;
}
}
}