Files
agent-framework/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs
T
Jacob Alber 0086d38f58 .NET: [BREAKING] Workflows API Review Naming Changes (Part 1?) (#4090)
* refactor: Normalize Run/RunStreaming with AIAgent

* refactor: Clarify Session vs. Run -level concepts

* Rename RunId to SessionId to better match Run/Session terminology in AIAgent
* [BREAKING]: Will break existing checkpointed sessions in CosmosDb due to field rename

* refactor: Rename and simplify interface around getting typed data out of ExternalRequest/Response

* Also adds hints around using value types in PortableValue

* refactor: Rename AddFanInEdge to AddFanInBarrierEdge

This will prevent a breaking change later when we introduce a programmable FanIn edge, analogous to the FanOut edge's EdgeSelector.

The goal, in the long run is to support a number of different FanIn scenarios, with naive FanIn (no barrier) by default, similar to FanOut.

* refactor: AsAgent(this Workflow, ...) => AsAIAgent(...)

* misc - part1: SwitchBuilder internal

---------

Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
2026-02-20 02:05:18 +00:00

385 lines
15 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
// Uncomment to output unknown content types for debugging.
//#define DEBUG_OUTPUT
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
namespace Shared.Workflows;
// Types are for evaluation purposes only and is subject to change or removal in future updates.
#pragma warning disable OPENAI001
#pragma warning disable OPENAICUA001
#pragma warning disable MEAI001
internal sealed class WorkflowRunner
{
private Dictionary<string, AIFunction> FunctionMap { get; }
private CheckpointInfo? LastCheckpoint { get; set; }
public static void Notify(string message, ConsoleColor? color = null)
{
Console.ForegroundColor = color ?? ConsoleColor.Cyan;
try
{
Console.WriteLine(message);
}
finally
{
Console.ResetColor();
}
}
/// <summary>
/// When enabled, checkpoints will be persisted to disk as JSON files.
/// Otherwise an in-memory checkpoint store that will not persist checkpoints
/// beyond the lifetime of the process.
/// </summary>
public bool UseJsonCheckpoints { get; init; }
public WorkflowRunner(params IEnumerable<AIFunction> functions)
{
this.FunctionMap = functions.ToDictionary(f => f.Name);
}
public async Task ExecuteAsync(Func<Workflow> workflowProvider, string input)
{
Workflow workflow = workflowProvider.Invoke();
CheckpointManager checkpointManager;
if (this.UseJsonCheckpoints)
{
// Use a file-system based JSON checkpoint store to persist checkpoints to disk.
DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:yyMMdd-hhmmss-ff}"));
checkpointManager = CheckpointManager.CreateJson(new FileSystemJsonCheckpointStore(checkpointFolder));
}
else
{
// Use an in-memory checkpoint store that will not persist checkpoints beyond the lifetime of the process.
checkpointManager = CheckpointManager.CreateInMemory();
}
StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input, checkpointManager).ConfigureAwait(false);
bool isComplete = false;
ExternalResponse? requestResponse = null;
do
{
ExternalRequest? externalRequest = await this.MonitorAndDisposeWorkflowRunAsync(run, requestResponse).ConfigureAwait(false);
if (externalRequest is not null)
{
Notify("\nWORKFLOW: Yield\n", ConsoleColor.DarkYellow);
if (this.LastCheckpoint is null)
{
throw new InvalidOperationException("Checkpoint information missing after external request.");
}
// Process the external request.
object response = await this.HandleExternalRequestAsync(externalRequest).ConfigureAwait(false);
requestResponse = externalRequest.CreateResponse(response);
// Let's resume on an entirely new workflow instance to demonstrate checkpoint portability.
workflow = workflowProvider.Invoke();
// Restore the latest checkpoint.
Debug.WriteLine($"RESTORE #{this.LastCheckpoint.CheckpointId}");
Notify("WORKFLOW: Restore", ConsoleColor.DarkYellow);
run = await InProcessExecution.ResumeStreamingAsync(workflow, this.LastCheckpoint, checkpointManager).ConfigureAwait(false);
}
else
{
isComplete = true;
}
}
while (!isComplete);
Notify("\nWORKFLOW: Done!\n");
}
public async Task<ExternalRequest?> MonitorAndDisposeWorkflowRunAsync(StreamingRun run, ExternalResponse? response = null)
{
#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task
await using IAsyncDisposable disposeRun = run;
#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task
bool hasStreamed = false;
string? messageId = null;
bool shouldExit = false;
ExternalRequest? externalResponse = null;
if (response is not null)
{
await run.SendResponseAsync(response).ConfigureAwait(false);
}
await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync().ConfigureAwait(false))
{
switch (workflowEvent)
{
case ExecutorInvokedEvent executorInvoked:
Debug.WriteLine($"EXECUTOR ENTER #{executorInvoked.ExecutorId}");
break;
case ExecutorCompletedEvent executorCompleted:
Debug.WriteLine($"EXECUTOR EXIT #{executorCompleted.ExecutorId}");
break;
case DeclarativeActionInvokedEvent actionInvoked:
Debug.WriteLine($"ACTION ENTER #{actionInvoked.ActionId} [{actionInvoked.ActionType}]");
break;
case DeclarativeActionCompletedEvent actionComplete:
Debug.WriteLine($"ACTION EXIT #{actionComplete.ActionId} [{actionComplete.ActionType}]");
break;
case ExecutorFailedEvent executorFailure:
Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}");
break;
case WorkflowErrorEvent workflowError:
throw workflowError.Data as Exception ?? new InvalidOperationException("Unexpected failure...");
case SuperStepCompletedEvent checkpointCompleted:
this.LastCheckpoint = checkpointCompleted.CompletionInfo?.Checkpoint;
Debug.WriteLine($"CHECKPOINT x{checkpointCompleted.StepNumber} [{this.LastCheckpoint?.CheckpointId ?? "(none)"}]");
if (externalResponse is not null)
{
shouldExit = true;
}
break;
case RequestInfoEvent requestInfo:
Debug.WriteLine($"REQUEST #{requestInfo.Request.RequestId}");
externalResponse = requestInfo.Request;
break;
case ConversationUpdateEvent invokeEvent:
Debug.WriteLine($"CONVERSATION: {invokeEvent.Data}");
break;
case MessageActivityEvent activityEvent:
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("\nACTIVITY:");
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine(activityEvent.Message.Trim());
Console.ResetColor();
break;
case AgentResponseUpdateEvent streamEvent:
if (!string.Equals(messageId, streamEvent.Update.MessageId, StringComparison.Ordinal))
{
hasStreamed = false;
messageId = streamEvent.Update.MessageId;
if (messageId is not null)
{
string? agentName = streamEvent.Update.AuthorName ?? streamEvent.Update.AgentId ?? nameof(ChatRole.Assistant);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write($"\n{agentName.ToUpperInvariant()}:");
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" [{messageId}]");
Console.ResetColor();
}
}
ChatResponseUpdate? chatUpdate = streamEvent.Update.RawRepresentation as ChatResponseUpdate;
switch (chatUpdate?.RawRepresentation)
{
case ImageGenerationCallResponseItem messageUpdate:
await DownloadFileContentAsync(Path.GetFileName("response.png"), messageUpdate.ImageResultBytes).ConfigureAwait(false);
break;
case FunctionCallResponseItem actionUpdate:
Console.ForegroundColor = ConsoleColor.White;
Console.Write($"Calling tool: {actionUpdate.FunctionName}");
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" [{actionUpdate.CallId}]");
Console.ResetColor();
break;
case McpToolCallItem actionUpdate:
Console.ForegroundColor = ConsoleColor.White;
Console.Write($"Calling tool: {actionUpdate.ToolName}");
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" [{actionUpdate.Id}]");
Console.ResetColor();
break;
}
try
{
Console.ResetColor();
Console.Write(streamEvent.Update.Text);
hasStreamed |= !string.IsNullOrEmpty(streamEvent.Update.Text);
}
finally
{
Console.ResetColor();
}
break;
case AgentResponseEvent messageEvent:
try
{
if (hasStreamed)
{
Console.WriteLine();
}
if (messageEvent.Response.Usage is not null)
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($"[Tokens Total: {messageEvent.Response.Usage.TotalTokenCount}, Input: {messageEvent.Response.Usage.InputTokenCount}, Output: {messageEvent.Response.Usage.OutputTokenCount}]");
Console.ResetColor();
}
}
finally
{
Console.ResetColor();
}
break;
default:
#if DEBUG_OUTPUT
Debug.WriteLine($"UNHANDLED: {workflowEvent.GetType().Name}");
#endif
break;
}
if (shouldExit)
{
break;
}
}
return externalResponse;
}
/// <summary>
/// Handle request for external input.
/// </summary>
private async ValueTask<ExternalInputResponse> HandleExternalRequestAsync(ExternalRequest request)
{
if (!request.TryGetDataAs<ExternalInputRequest>(out var inputRequest))
{
throw new InvalidOperationException($"Expected external request type: {request.PortInfo.RequestType}.");
}
List<ChatMessage> responseMessages = [];
foreach (ChatMessage message in inputRequest.AgentResponse.Messages)
{
await foreach (ChatMessage responseMessage in this.ProcessInputMessageAsync(message).ConfigureAwait(false))
{
responseMessages.Add(responseMessage);
}
}
if (responseMessages.Count == 0)
{
// Must be request for user input.
responseMessages.Add(HandleUserInputRequest(inputRequest));
}
Console.WriteLine();
return new ExternalInputResponse(responseMessages);
}
private async IAsyncEnumerable<ChatMessage> ProcessInputMessageAsync(ChatMessage message)
{
foreach (AIContent requestItem in message.Contents)
{
ChatMessage? responseMessage =
requestItem switch
{
FunctionCallContent functionCall when !functionCall.InformationalOnly => await InvokeFunctionAsync(functionCall).ConfigureAwait(false),
FunctionApprovalRequestContent functionApprovalRequest => ApproveFunction(functionApprovalRequest),
McpServerToolApprovalRequestContent mcpApprovalRequest => ApproveMCP(mcpApprovalRequest),
_ => HandleUnknown(requestItem),
};
if (responseMessage is not null)
{
yield return responseMessage;
}
}
ChatMessage? HandleUnknown(AIContent request)
{
#if DEBUG_OUTPUT
Notify($"INPUT - Unknown: {request.GetType().Name} [{request.RawRepresentation?.GetType().Name ?? "*"}]");
#endif
return null;
}
ChatMessage ApproveFunction(FunctionApprovalRequestContent functionApprovalRequest)
{
Notify($"INPUT - Approving Function: {functionApprovalRequest.FunctionCall.Name}");
return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved: true)]);
}
ChatMessage ApproveMCP(McpServerToolApprovalRequestContent mcpApprovalRequest)
{
Notify($"INPUT - Approving MCP: {mcpApprovalRequest.ToolCall.ToolName}");
return new ChatMessage(ChatRole.User, [mcpApprovalRequest.CreateResponse(approved: true)]);
}
async Task<ChatMessage> InvokeFunctionAsync(FunctionCallContent functionCall)
{
Notify($"INPUT - Executing Function: {functionCall.Name}");
AIFunction functionTool = this.FunctionMap[functionCall.Name];
AIFunctionArguments? functionArguments = functionCall.Arguments is null ? null : new(functionCall.Arguments.NormalizePortableValues());
object? result = await functionTool.InvokeAsync(functionArguments).ConfigureAwait(false);
return new ChatMessage(ChatRole.Tool, [new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result))]);
}
}
private static ChatMessage HandleUserInputRequest(ExternalInputRequest request)
{
string prompt =
string.IsNullOrWhiteSpace(request.AgentResponse.Text) || request.AgentResponse.ResponseId is not null ?
"INPUT:" :
request.AgentResponse.Text;
string? userInput;
do
{
Console.ForegroundColor = ConsoleColor.DarkGreen;
Console.Write($"{prompt} ");
Console.ForegroundColor = ConsoleColor.White;
userInput = Console.ReadLine();
}
while (string.IsNullOrWhiteSpace(userInput));
return new ChatMessage(ChatRole.User, userInput);
}
private static async ValueTask DownloadFileContentAsync(string filename, BinaryData content)
{
string filePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(filename));
filePath = Path.ChangeExtension(filePath, ".png");
await File.WriteAllBytesAsync(filePath, content.ToArray()).ConfigureAwait(false);
Process.Start(
new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/C start {filePath}"
});
}
}