// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.InProc;
///
/// Provides a local, in-process runner for executing a workflow using the specified input type.
///
/// enables step-by-step execution of a workflow graph entirely
/// within the current process, without distributed coordination. It is primarily intended for testing, debugging, or
/// scenarios where workflow execution does not require executor distribution.
internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
{
public InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, object? workflowOwnership = null, bool subworkflow = false, IEnumerable? knownValidInputTypes = null)
{
this.RunId = runId ?? 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, workflowOwnership, subworkflow);
this.CheckpointManager = checkpointManager;
this._knownValidInputTypes = knownValidInputTypes != null
? [.. knownValidInputTypes]
: [];
// Initialize the runners for each of the edges, along with the state for edges that
// need it.
this.EdgeMap = new EdgeMap(this.RunContext, this.Workflow.Edges, this.Workflow.Ports.Values, this.Workflow.StartExecutorId, this.StepTracer);
}
///
public string RunId { get; }
///
public string StartExecutorId { get; }
private readonly HashSet _knownValidInputTypes;
public async ValueTask IsValidInputTypeAsync(Type messageType, CancellationToken cancellation = default)
{
if (this._knownValidInputTypes.Contains(messageType))
{
return true;
}
Executor startingExecutor = await this.RunContext.EnsureExecutorAsync(this.Workflow.StartExecutorId, tracer: null).ConfigureAwait(false);
if (startingExecutor.CanHandle(messageType))
{
this._knownValidInputTypes.Add(messageType);
return true;
}
return false;
}
public ValueTask IsValidInputTypeAsync(CancellationToken cancellation = default)
=> this.IsValidInputTypeAsync(typeof(T), cancellation);
public async ValueTask EnqueueMessageUntypedAsync(object message, Type declaredType, CancellationToken cancellation = default)
{
this.RunContext.CheckEnded();
Throw.IfNull(message);
if (message is ExternalResponse response)
{
await this.RunContext.AddExternalResponseAsync(response).ConfigureAwait(false);
}
// Check that the type of the incoming message is compatible with the starting executor's
// input type.
if (!await this.IsValidInputTypeAsync(declaredType, cancellation).ConfigureAwait(false))
{
return false;
}
await this.RunContext.AddExternalMessageAsync(message, declaredType).ConfigureAwait(false);
return true;
}
public ValueTask EnqueueMessageAsync(T message, CancellationToken cancellation = default)
=> this.EnqueueMessageUntypedAsync(Throw.IfNull(message), typeof(T), cancellation);
public ValueTask EnqueueMessageAsync(object message, CancellationToken cancellation = default)
=> this.EnqueueMessageUntypedAsync(Throw.IfNull(message), message.GetType(), cancellation);
ValueTask ISuperStepRunner.EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellation)
{
// TODO: Check that there exists a corresponding input port?
return this.RunContext.AddExternalResponseAsync(response);
}
private InProcStepTracer StepTracer { get; } = new();
private Workflow Workflow { get; init; }
internal InProcessRunnerContext RunContext { get; init; }
private ICheckpointManager? CheckpointManager { get; }
private EdgeMap EdgeMap { get; init; }
public ConcurrentEventSink OutgoingEvents { get; } = new();
private ValueTask RaiseWorkflowEventAsync(WorkflowEvent workflowEvent)
=> this.OutgoingEvents.EnqueueAsync(workflowEvent);
public ValueTask BeginStreamAsync(ExecutionMode mode, CancellationToken cancellation = default)
{
this.RunContext.CheckEnded();
return new(new AsyncRunHandle(this, this, mode));
}
public async ValueTask ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, CancellationToken cancellation = default)
{
this.RunContext.CheckEnded();
Throw.IfNull(fromCheckpoint);
if (this.CheckpointManager is null)
{
throw new InvalidOperationException("This runner was not configured with a CheckpointManager, so it cannot restore checkpoints.");
}
await this.RestoreCheckpointAsync(fromCheckpoint, cancellation).ConfigureAwait(false);
return new AsyncRunHandle(this, this, mode);
}
bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests;
bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions;
public IReadOnlyList Checkpoints => this._checkpoints;
async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cancellationToken)
{
this.RunContext.CheckEnded();
if (cancellationToken.IsCancellationRequested)
{
return false;
}
StepContext currentStep = await this.RunContext.AdvanceAsync().ConfigureAwait(false);
if (currentStep.HasMessages ||
this.RunContext.HasQueuedExternalDeliveries ||
this.RunContext.JoinedRunnersHaveActions)
{
try
{
await this.RunSuperstepAsync(currentStep).ConfigureAwait(false);
}
catch (OperationCanceledException)
{ }
catch (Exception e)
{
await this.RaiseWorkflowEventAsync(new WorkflowErrorEvent(e)).ConfigureAwait(false);
}
return true;
}
return false;
}
private async ValueTask DeliverMessagesAsync(string receiverId, ConcurrentQueue envelopes)
{
Executor executor = await this.RunContext.EnsureExecutorAsync(receiverId, this.StepTracer).ConfigureAwait(false);
this.StepTracer.TraceActivated(receiverId);
while (envelopes.TryDequeue(out var envelope))
{
await executor.ExecuteAsync(
envelope.Message,
envelope.MessageType,
this.RunContext.Bind(receiverId, envelope.TraceContext)
).ConfigureAwait(false);
}
}
private async ValueTask RunSuperstepAsync(StepContext currentStep)
{
await this.RaiseWorkflowEventAsync(this.StepTracer.Advance(currentStep)).ConfigureAwait(false);
// Deliver the messages and queue the next step
List receiverTasks =
currentStep.QueuedMessages.Keys
.Select(receiverId => this.DeliverMessagesAsync(receiverId, currentStep.MessagesFor(receiverId)).AsTask())
.ToList();
// TODO: Should we let the user specify that they want strictly turn-based execution of the edges, vs. concurrent?
// (Simply substitute a strategy that replaces Task.WhenAll with a loop with an await in the middle. Difficulty is
// that we would need to avoid firing the tasks when we call InvokeEdgeAsync, or RouteExternalMessageAsync.
await Task.WhenAll(receiverTasks).ConfigureAwait(false);
// When we have sub-workflows, sending a message to the WorkflowHostExecutor will only queue it into the
// subworkflow's input queue. In order to actually process the message and align the supersteps correctly,
// we need to drive the superstep of the subworkflow here.
// TODO: Investigate if we can fully pull in the subworkflow execution into the WorkflowHostExecutor itself.
List subworkflowTasks = new();
foreach (ISuperStepRunner subworkflowRunner in this.RunContext.JoinedSubworkflowRunners)
{
subworkflowTasks.Add(subworkflowRunner.RunSuperStepAsync(CancellationToken.None).AsTask());
}
await Task.WhenAll(subworkflowTasks).ConfigureAwait(false);
await this.CheckpointAsync().ConfigureAwait(false);
await this.RaiseWorkflowEventAsync(this.StepTracer.Complete(this.RunContext.NextStepHasActions, this.RunContext.HasUnservicedRequests))
.ConfigureAwait(false);
}
private WorkflowInfo? _workflowInfoCache;
private readonly List _checkpoints = [];
internal async ValueTask CheckpointAsync(CancellationToken cancellationToken = default)
{
this.RunContext.CheckEnded();
if (this.CheckpointManager is null)
{
// Always publish the state updates, even in the absence of a CheckpointManager.
await this.RunContext.StateManager.PublishUpdatesAsync(this.StepTracer).ConfigureAwait(false);
return;
}
// Notify all the executors that they should prepare for checkpointing.
Task prepareTask = this.RunContext.PrepareForCheckpointAsync(cancellationToken);
// Create a representation of the current workflow if it does not already exist.
this._workflowInfoCache ??= this.Workflow.ToWorkflowInfo();
Dictionary edgeData = await this.EdgeMap.ExportStateAsync().ConfigureAwait(false);
await prepareTask.ConfigureAwait(false);
await this.RunContext.StateManager.PublishUpdatesAsync(this.StepTracer).ConfigureAwait(false);
RunnerStateData runnerData = await this.RunContext.ExportStateAsync().ConfigureAwait(false);
Dictionary 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);
}
public async ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default)
{
this.RunContext.CheckEnded();
Throw.IfNull(checkpointInfo);
if (this.CheckpointManager is null)
{
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)
.ConfigureAwait(false);
// Validate the checkpoint is compatible with this workflow
if (!this.CheckWorkflowMatch(checkpoint))
{
// TODO: ArgumentException?
throw new InvalidDataException("The specified checkpoint is not compatible with the workflow associated with this runner.");
}
await this.RunContext.StateManager.ImportStateAsync(checkpoint).ConfigureAwait(false);
await this.RunContext.ImportStateAsync(checkpoint).ConfigureAwait(false);
Task executorNotifyTask = this.RunContext.NotifyCheckpointLoadedAsync(cancellationToken);
ValueTask republishRequestsTask = this.RunContext.RepublishUnservicedRequestsAsync(cancellationToken);
await this.EdgeMap.ImportStateAsync(checkpoint).ConfigureAwait(false);
await Task.WhenAll(executorNotifyTask, republishRequestsTask.AsTask()).ConfigureAwait(false);
this.StepTracer.Reload(this.StepTracer.StepNumber);
}
private bool CheckWorkflowMatch(Checkpoint checkpoint) =>
checkpoint.Workflow.IsMatch(this.Workflow);
public ValueTask RequestEndRunAsync() => this.RunContext.EndRunAsync();
}