mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* refactor: remove unused internals * feat: Execution Mode for sharing a workflow among concurrent runs * feat: Update WorkflowHostAgent to support concurrent execution * Also update AsAgent APIs to support injecting a CheckpointManager and an IWorkflowExecutionEnvironment * fix: Make Read logic consistent in DeclarativeWorkflowContext
290 lines
12 KiB
C#
290 lines
12 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
|
using Microsoft.Agents.AI.Workflows.Execution;
|
|
using Microsoft.Agents.AI.Workflows.InProc;
|
|
using Microsoft.Shared.Diagnostics;
|
|
|
|
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
|
|
|
internal class WorkflowHostExecutor : Executor, IAsyncDisposable
|
|
{
|
|
private readonly string _runId;
|
|
private readonly Workflow _workflow;
|
|
private readonly object _ownershipToken;
|
|
|
|
private InProcessRunner? _activeRunner;
|
|
private InMemoryCheckpointManager? _checkpointManager;
|
|
private readonly ExecutorOptions _options;
|
|
|
|
private ISuperStepJoinContext? _joinContext;
|
|
private string? _joinId;
|
|
private StreamingRun? _run;
|
|
|
|
[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)
|
|
{
|
|
this._options = options ?? new();
|
|
|
|
Throw.IfNull(workflow);
|
|
this._runId = Throw.IfNull(runId);
|
|
this._ownershipToken = Throw.IfNull(ownershipToken);
|
|
this._workflow = Throw.IfNull(workflow);
|
|
}
|
|
|
|
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
|
|
{
|
|
return routeBuilder.AddCatchAll(this.QueueExternalMessageAsync);
|
|
}
|
|
|
|
private async ValueTask QueueExternalMessageAsync(PortableValue portableValue, IWorkflowContext context, CancellationToken cancellationToken)
|
|
{
|
|
if (portableValue.Is(out ExternalResponse? response))
|
|
{
|
|
response = this.CheckAndUnqualifyResponse(response);
|
|
await this.EnsureRunSendMessageAsync(response, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
InProcessRunner runner = await this.EnsureRunnerAsync().ConfigureAwait(false);
|
|
IEnumerable<Type> validInputTypes = await runner.RunContext.GetStartingExecutorInputTypesAsync(cancellationToken).ConfigureAwait(false);
|
|
foreach (Type candidateType in validInputTypes)
|
|
{
|
|
if (portableValue.IsType(candidateType, out object? message))
|
|
{
|
|
await this.EnsureRunSendMessageAsync(message, candidateType, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private ISuperStepJoinContext JoinContext => Throw.IfNull(this._joinContext, "Must attach to a join context before starting the run.");
|
|
|
|
internal async ValueTask<InProcessRunner> EnsureRunnerAsync()
|
|
{
|
|
if (this._activeRunner == null)
|
|
{
|
|
if (this.JoinContext.WithCheckpointing)
|
|
{
|
|
// 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,
|
|
// if needed. For our purposes, all we need is to keep a faithful representation of the checkpointed
|
|
// objects so we can emit them back to the parent workflow on checkpoint creation.
|
|
this._checkpointManager = new InMemoryCheckpointManager();
|
|
}
|
|
|
|
this._activeRunner = InProcessRunner.CreateSubworkflowRunner(this._workflow,
|
|
this._checkpointManager,
|
|
this._runId,
|
|
this._ownershipToken,
|
|
this.JoinContext.ConcurrentRunsEnabled);
|
|
}
|
|
|
|
return this._activeRunner;
|
|
}
|
|
|
|
internal async ValueTask<StreamingRun> EnsureRunSendMessageAsync(object? incomingMessage = null, Type? incomingMessageType = null, bool resume = false, CancellationToken cancellationToken = default)
|
|
{
|
|
Debug.Assert(this._joinContext != null, "Must attach to a join context before starting the run.");
|
|
|
|
if (this._run != null)
|
|
{
|
|
if (incomingMessage != null)
|
|
{
|
|
await this._run.TrySendMessageUntypedAsync(incomingMessage, incomingMessageType ?? incomingMessage.GetType()).ConfigureAwait(false);
|
|
}
|
|
|
|
return this._run;
|
|
}
|
|
|
|
InProcessRunner activeRunner = await this.EnsureRunnerAsync().ConfigureAwait(false);
|
|
AsyncRunHandle runHandle;
|
|
|
|
if (this.WithCheckpointing)
|
|
{
|
|
if (resume)
|
|
{
|
|
// Attempting to resume from checkpoint
|
|
if (!this._checkpointManager.TryGetLastCheckpoint(this._runId, out CheckpointInfo? lastCheckpoint))
|
|
{
|
|
throw new InvalidOperationException("No checkpoints available to resume from.");
|
|
}
|
|
|
|
runHandle = await activeRunner.ResumeStreamAsync(ExecutionMode.Subworkflow, lastCheckpoint!, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (incomingMessage != null)
|
|
{
|
|
await runHandle.EnqueueUntypedAndRunAsync(incomingMessage, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
else if (incomingMessage != null)
|
|
{
|
|
runHandle = await activeRunner.BeginStreamAsync(ExecutionMode.Subworkflow, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
await runHandle.EnqueueUntypedAndRunAsync(incomingMessage, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException("Cannot start a checkpointed workflow run without an incoming message or resume flag.");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
runHandle = await activeRunner.BeginStreamAsync(ExecutionMode.Subworkflow, cancellationToken).ConfigureAwait(false);
|
|
|
|
await runHandle.EnqueueMessageUntypedAsync(Throw.IfNull(incomingMessage), cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
this._run = new(runHandle);
|
|
|
|
this._joinId = await this._joinContext.AttachSuperstepAsync(activeRunner, cancellationToken).ConfigureAwait(false);
|
|
activeRunner.OutgoingEvents.EventRaised += this.ForwardWorkflowEventAsync;
|
|
|
|
return this._run;
|
|
}
|
|
|
|
private ExternalResponse? CheckAndUnqualifyResponse([DisallowNull] ExternalResponse response)
|
|
{
|
|
if (!Throw.IfNull(response).PortInfo.PortId.StartsWith($"{this.Id}.", StringComparison.Ordinal))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
RequestPortInfo unqualifiedPort = response.PortInfo with { PortId = response.PortInfo.PortId.Substring(this.Id.Length + 1) };
|
|
return response with { PortInfo = unqualifiedPort };
|
|
}
|
|
|
|
private ExternalRequest QualifyRequestPortId(ExternalRequest internalRequest)
|
|
{
|
|
RequestPortInfo requestPort = internalRequest.PortInfo with { PortId = $"{this.Id}.{internalRequest.PortInfo.PortId}" };
|
|
return internalRequest with { PortInfo = requestPort };
|
|
}
|
|
|
|
private async ValueTask ForwardWorkflowEventAsync(object? sender, WorkflowEvent evt)
|
|
{
|
|
// Note that we are explicitly not using the checked JoinContext property here, because this is an async callback.
|
|
try
|
|
{
|
|
Task resultTask = Task.CompletedTask;
|
|
switch (evt)
|
|
{
|
|
case WorkflowStartedEvent:
|
|
case SuperStepStartedEvent:
|
|
case SuperStepCompletedEvent:
|
|
// These events are internal to the subworkflow and do not need to be forwarded.
|
|
break;
|
|
case RequestInfoEvent requestInfoEvt:
|
|
ExternalRequest request = requestInfoEvt.Request;
|
|
resultTask = this._joinContext?.SendMessageAsync(this.Id, this.QualifyRequestPortId(request)).AsTask() ?? Task.CompletedTask;
|
|
break;
|
|
case WorkflowErrorEvent errorEvent:
|
|
resultTask = this._joinContext?.ForwardWorkflowEventAsync(new SubworkflowErrorEvent(this.Id, errorEvent.Data as Exception)).AsTask() ?? Task.CompletedTask;
|
|
break;
|
|
case WorkflowOutputEvent outputEvent:
|
|
if (this._joinContext != null &&
|
|
this._options.AutoSendMessageHandlerResultObject
|
|
&& outputEvent.Data != null)
|
|
{
|
|
resultTask = this._joinContext.SendMessageAsync(this.Id, outputEvent.Data).AsTask();
|
|
}
|
|
break;
|
|
case RequestHaltEvent requestHaltEvent:
|
|
resultTask = this._joinContext?.ForwardWorkflowEventAsync(new RequestHaltEvent()).AsTask() ?? Task.CompletedTask;
|
|
break;
|
|
case WorkflowWarningEvent warningEvent:
|
|
if (warningEvent.Data is string warningMessage)
|
|
{
|
|
resultTask = this._joinContext?.ForwardWorkflowEventAsync(new SubworkflowWarningEvent(this.Id, warningMessage)).AsTask() ?? Task.CompletedTask;
|
|
}
|
|
break;
|
|
default:
|
|
resultTask = this._joinContext?.ForwardWorkflowEventAsync(evt).AsTask() ?? Task.CompletedTask;
|
|
break;
|
|
}
|
|
|
|
await resultTask.ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
try
|
|
{
|
|
_ = this._joinContext?.ForwardWorkflowEventAsync(new SubworkflowErrorEvent(this.Id, ex)).AsTask();
|
|
}
|
|
catch
|
|
{ }
|
|
}
|
|
}
|
|
|
|
internal async ValueTask AttachSuperStepContextAsync(ISuperStepJoinContext joinContext)
|
|
{
|
|
this._joinContext = Throw.IfNull(joinContext);
|
|
}
|
|
|
|
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
|
{
|
|
await context.QueueStateUpdateAsync(nameof(CheckpointManager), this._checkpointManager, 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);
|
|
|
|
InMemoryCheckpointManager manager = await context.ReadStateAsync<InMemoryCheckpointManager>(nameof(InMemoryCheckpointManager), cancellationToken: cancellationToken).ConfigureAwait(false) ?? new();
|
|
if (this._checkpointManager == manager)
|
|
{
|
|
// We are restoring in the context of the same run; not need to rebuild the entire execution stack.
|
|
}
|
|
else
|
|
{
|
|
this._checkpointManager = manager;
|
|
|
|
await this.ResetAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
StreamingRun run = await this.EnsureRunSendMessageAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private async ValueTask ResetAsync()
|
|
{
|
|
if (this._run != null)
|
|
{
|
|
await this._run.DisposeAsync().ConfigureAwait(false);
|
|
this._run = null;
|
|
}
|
|
|
|
if (this._activeRunner != null)
|
|
{
|
|
this._activeRunner.OutgoingEvents.EventRaised -= this.ForwardWorkflowEventAsync;
|
|
await this._activeRunner.RequestEndRunAsync().ConfigureAwait(false);
|
|
|
|
this._activeRunner = null;
|
|
}
|
|
|
|
if (this._joinContext != null)
|
|
{
|
|
if (this._joinId != null)
|
|
{
|
|
await this._joinContext.DetachSuperstepAsync(this._joinId).ConfigureAwait(false);
|
|
this._joinId = null;
|
|
}
|
|
|
|
this._joinContext = null;
|
|
}
|
|
}
|
|
|
|
public ValueTask DisposeAsync() => this.ResetAsync();
|
|
}
|