using System.IO; using System.Linq; using Aryx.AgentHost.Contracts; using GitHub.Copilot.SDK; using Microsoft.Agents.AI.Workflows; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Extensions.AI; namespace Aryx.AgentHost.Services; public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner { private const string HandoffFunctionPrefix = "handoff_to_"; private readonly PatternValidator _patternValidator; private readonly WorkflowValidator _workflowValidator; private readonly WorkflowRunner _workflowRunner = new(); private readonly CopilotApprovalCoordinator _approvalCoordinator = new(); private readonly CopilotUserInputCoordinator _userInputCoordinator = new(); private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new(); private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new(); public CopilotWorkflowRunner(PatternValidator patternValidator, WorkflowValidator? workflowValidator = null) { _patternValidator = patternValidator; _workflowValidator = workflowValidator ?? new WorkflowValidator(); } public async Task> RunTurnAsync( RunTurnCommandDto command, Func onDelta, Func onEvent, Func onApproval, Func onUserInput, Func onMcpOAuthRequired, Func onExitPlanMode, CancellationToken cancellationToken) { string? validationError = command.Workflow is null ? _patternValidator.Validate(command.Pattern).FirstOrDefault()?.Message : _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary).FirstOrDefault()?.Message; if (validationError is not null) { throw new InvalidOperationException(validationError); } CopilotTurnExecutionState state = new(command); using CancellationTokenSource runCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); try { await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync( command, (agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync( command, agent, request, invocation, state.ToolNamesByCallId, activity => EmitActivityAsync(command, state, activity, onEvent), onApproval, runCancellation.Token), (agent, request, invocation) => _userInputCoordinator.RequestUserInputAsync( command, agent, request, invocation, onUserInput, runCancellation.Token), (agent, sessionEvent) => { state.ObserveSessionEvent(agent, sessionEvent); if (sessionEvent is McpOauthRequiredEvent mcpOauthRequired) { state.EnqueuePendingMcpOauthRequest( _mcpOAuthCoordinator.BuildMcpOauthRequiredEvent(command, agent, mcpOauthRequired)); } if (sessionEvent is ExitPlanModeRequestedEvent exitPlanModeRequested) { _exitPlanModeCoordinator.RecordExitPlanModeRequest(command, agent, exitPlanModeRequested); runCancellation.Cancel(); } }, runCancellation.Token); ConfigureHookLifecycleEventSuppression(state, bundle); Workflow workflow = command.Workflow is null ? bundle.BuildWorkflow(command.Pattern) : _workflowRunner.BuildWorkflow(command.Workflow, command.Pattern, bundle.Agents, command.WorkflowLibrary); List inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList(); WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode); using FileSystemJsonCheckpointStore? checkpointStore = CreateCheckpointStore(command); CheckpointManager? checkpointManager = checkpointStore is not null ? CheckpointManager.CreateJson(checkpointStore) : null; await using StreamingRun run = await OpenWorkflowRunAsync( command, workflow, inputMessages, checkpointManager).ConfigureAwait(false); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); await foreach (WorkflowEvent evt in run.WatchStreamAsync(runCancellation.Token).ConfigureAwait(false)) { bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onEvent) .ConfigureAwait(false); await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false); await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false); if (shouldEndTurn) { break; } } await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false); await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false); return state.FinalizeCompletedMessages(); } catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false); await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false); ExitPlanModeRequestedEventDto? exitPlanModeEvent = _exitPlanModeCoordinator.ConsumePendingRequest(command.RequestId); if (exitPlanModeEvent is null || !state.HasPendingExitPlanModeRequest) { throw; } await onExitPlanMode(exitPlanModeEvent).ConfigureAwait(false); return state.FinalizeCompletedMessages(); } finally { _approvalCoordinator.ClearRequestApprovals(command.RequestId); } } internal static FileSystemJsonCheckpointStore? CreateCheckpointStore(RunTurnCommandDto command) { if (!ShouldEnableWorkflowCheckpointing(command)) { return null; } DirectoryInfo checkpointDirectory = new(GetCheckpointStorePath(command)); return new FileSystemJsonCheckpointStore(checkpointDirectory); } internal static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command) { ArgumentNullException.ThrowIfNull(command); if (command.Workflow is not null) { return command.Workflow.Settings.Checkpointing.Enabled; } return string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase); } internal static string GetCheckpointStorePath(RunTurnCommandDto command) { ArgumentNullException.ThrowIfNull(command); if (!string.IsNullOrWhiteSpace(command.ResumeFromCheckpoint?.StorePath)) { return command.ResumeFromCheckpoint.StorePath; } string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); return Path.Combine(localAppData, "Aryx", "workflow-checkpoints", command.SessionId, command.RequestId); } private static ValueTask OpenWorkflowRunAsync( RunTurnCommandDto command, Workflow workflow, IReadOnlyList inputMessages, CheckpointManager? checkpointManager) { if (checkpointManager is not null && command.ResumeFromCheckpoint is { } resumeFromCheckpoint) { return InProcessExecution.ResumeStreamingAsync( workflow, new CheckpointInfo(resumeFromCheckpoint.WorkflowSessionId, resumeFromCheckpoint.CheckpointId), checkpointManager); } if (checkpointManager is not null) { return InProcessExecution.RunStreamingAsync( workflow, inputMessages.ToList(), checkpointManager, sessionId: command.RequestId); } return InProcessExecution.RunStreamingAsync(workflow, inputMessages.ToList()); } internal static void ConfigureHookLifecycleEventSuppression( CopilotTurnExecutionState state, CopilotAgentBundle bundle) { ArgumentNullException.ThrowIfNull(state); ArgumentNullException.ThrowIfNull(bundle); state.SuppressHookLifecycleEvents = !bundle.HasConfiguredHooks; } private static async Task EmitPendingEventsAsync( CopilotTurnExecutionState state, Func onEvent) { foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents()) { await onEvent(pendingEvent).ConfigureAwait(false); } } private static async Task EmitPendingMcpOauthRequestsAsync( CopilotTurnExecutionState state, Func onMcpOAuthRequired) { foreach (McpOauthRequiredEventDto request in state.DrainPendingMcpOauthRequests()) { await onMcpOAuthRequired(request).ConfigureAwait(false); } } public Task ResolveApprovalAsync( ResolveApprovalCommandDto command, CancellationToken cancellationToken) { return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken); } public Task ResolveUserInputAsync( ResolveUserInputCommandDto command, CancellationToken cancellationToken) { return _userInputCoordinator.ResolveUserInputAsync(command, cancellationToken); } private static async Task HandleWorkflowEventAsync( RunTurnCommandDto command, WorkflowEvent evt, IReadOnlyList inputMessages, CopilotTurnExecutionState state, Func onDelta, Func onEvent) { if (evt is ExecutorInvokedEvent invoked) { if (AgentIdentityResolver.TryResolveKnownAgentIdentity( command.Pattern, invoked.ExecutorId, out AgentIdentity invokedAgent)) { TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId})."); await state.EmitThinkingIfNeeded(invokedAgent, onEvent).ConfigureAwait(false); } else { TraceHandoff(command, $"Executor invoked without a known agent match: {invoked.ExecutorId}."); } return false; } if (evt is RequestInfoEvent requestInfo) { AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( command, requestInfo, state.ActiveAgent, state.ToolNamesByCallId); if (activity is null) { bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(command, requestInfo); TraceHandoff( command, $"Request info produced no activity for data type '{requestInfo.Request.Data.TypeId}'. Requires boundary: {requiresBoundary}."); return requiresBoundary; } await EmitActivityAsync(command, state, activity, onEvent).ConfigureAwait(false); return false; } if (TryCreateWorkflowCheckpointSavedEvent(command, evt, out WorkflowCheckpointSavedEventDto? checkpointSaved)) { await onEvent(checkpointSaved).ConfigureAwait(false); return false; } if (TryCreateWorkflowDiagnosticEvent(command, evt, state, out WorkflowDiagnosticEventDto? diagnostic)) { await onEvent(diagnostic).ConfigureAwait(false); return false; } if (evt is AgentResponseUpdateEvent update) { await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onEvent).ConfigureAwait(false); return false; } if (evt is ExecutorCompletedEvent completed) { if (AgentIdentityResolver.TryResolveObservedAgentIdentity( command.Pattern, completed.ExecutorId, state.ActiveAgent, out AgentIdentity completedAgent)) { TraceHandoff(command, $"Executor completed: {completed.ExecutorId} -> {completedAgent.AgentName} ({completedAgent.AgentId})."); state.ClearActiveAgentIfMatching(completedAgent); } else { TraceHandoff(command, $"Executor completed without a known agent match: {completed.ExecutorId}."); } return false; } if (evt is WorkflowOutputEvent outputEvent) { List allMessages = outputEvent.As>() ?? []; state.UpdateCompletedMessages(allMessages, inputMessages); } return false; } private static async Task HandleAgentResponseUpdateAsync( RunTurnCommandDto command, AgentResponseUpdateEvent update, CopilotTurnExecutionState state, Func onDelta, Func onEvent) { AgentIdentity? updateAgent = null; string authorName = update.ExecutorId; string[] handoffFunctionCalls = update.Update.Contents .OfType() .Select(content => content.Name) .Where(IsHandoffFunctionName) .Distinct(StringComparer.Ordinal) .ToArray(); if (state.TryResolveObservedAgentForMessage(update.Update.MessageId, out AgentIdentity observedMessageAgent)) { updateAgent = observedMessageAgent; authorName = observedMessageAgent.AgentName; } else if (AgentIdentityResolver.TryResolveObservedAgentIdentity( command.Pattern, update.ExecutorId, state.ActiveAgent, out AgentIdentity resolvedUpdateAgent)) { updateAgent = resolvedUpdateAgent; authorName = resolvedUpdateAgent.AgentName; } else if (state.ActiveAgent is AgentIdentity activeAgent) { updateAgent = activeAgent; authorName = activeAgent.AgentName; TraceHandoff( command, $"Agent response update fell back to active agent {activeAgent.AgentName} ({activeAgent.AgentId}) for executor '{update.ExecutorId}' and message '{update.Update.MessageId ?? ""}'."); } if (updateAgent.HasValue) { if (handoffFunctionCalls.Length > 0) { TraceHandoff( command, $"Agent response update from {updateAgent.Value.AgentName} ({updateAgent.Value.AgentId}) requested handoff via {string.Join(", ", handoffFunctionCalls)}."); } await state.EmitThinkingIfNeeded(updateAgent.Value, onEvent).ConfigureAwait(false); } else if (!string.IsNullOrEmpty(update.Update.Text) || handoffFunctionCalls.Length > 0) { TraceHandoff( command, $"Agent response update could not resolve agent for executor '{update.ExecutorId}' and message '{update.Update.MessageId ?? ""}'."); } if (string.IsNullOrEmpty(update.Update.Text)) { return; } string messageId = state.CreateMessageId(update.Update.MessageId); (string _, string currentAuthorName, string currentContent) = state.AppendDelta( messageId, authorName, update.Update.Text); await onDelta(new TurnDeltaEventDto { Type = "turn-delta", RequestId = command.RequestId, SessionId = command.SessionId, MessageId = messageId, AuthorName = currentAuthorName, ContentDelta = update.Update.Text, Content = currentContent, }).ConfigureAwait(false); } private static async Task EmitActivityAsync( RunTurnCommandDto command, CopilotTurnExecutionState state, AgentActivityEventDto activity, Func onEvent) { state.ApplyEvent(activity); TraceHandoff( command, $"Activity emitted: {activity.ActivityType} -> {activity.AgentName ?? activity.AgentId ?? ""}."); await onEvent(activity).ConfigureAwait(false); if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(activity.AgentId) && !string.IsNullOrWhiteSpace(activity.AgentName)) { TraceHandoff( command, $"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId})."); await state.EmitThinkingIfNeeded( new AgentIdentity(activity.AgentId, activity.AgentName), onEvent).ConfigureAwait(false); } } internal static bool TryCreateWorkflowCheckpointSavedEvent( RunTurnCommandDto command, WorkflowEvent evt, out WorkflowCheckpointSavedEventDto checkpointSaved) { checkpointSaved = default!; if (!ShouldEnableWorkflowCheckpointing(command) || evt is not SuperStepCompletedEvent superStepCompleted || superStepCompleted.CompletionInfo?.Checkpoint is not CheckpointInfo checkpoint) { return false; } checkpointSaved = new WorkflowCheckpointSavedEventDto { Type = "workflow-checkpoint-saved", RequestId = command.RequestId, SessionId = command.SessionId, WorkflowSessionId = checkpoint.SessionId, CheckpointId = checkpoint.CheckpointId, StorePath = GetCheckpointStorePath(command), StepNumber = superStepCompleted.StepNumber, }; return true; } private static bool TryCreateWorkflowDiagnosticEvent( RunTurnCommandDto command, WorkflowEvent evt, CopilotTurnExecutionState state, out WorkflowDiagnosticEventDto diagnostic) { diagnostic = default!; switch (evt) { case ExecutorFailedEvent executorFailed: { AgentIdentity? agent = AgentIdentityResolver.TryResolveObservedAgentIdentity( command.Pattern, executorFailed.ExecutorId, state.ActiveAgent, out AgentIdentity resolvedAgent) ? resolvedAgent : null; Exception? exception = executorFailed.Data; diagnostic = new WorkflowDiagnosticEventDto { Type = "workflow-diagnostic", RequestId = command.RequestId, SessionId = command.SessionId, Severity = "error", DiagnosticKind = "executor-failed", Message = ResolveDiagnosticMessage(exception, "Executor failed."), AgentId = agent?.AgentId, AgentName = agent?.AgentName, ExecutorId = executorFailed.ExecutorId, ExceptionType = exception?.GetBaseException().GetType().Name, }; return true; } case WorkflowWarningEvent workflowWarning: diagnostic = new WorkflowDiagnosticEventDto { Type = "workflow-diagnostic", RequestId = command.RequestId, SessionId = command.SessionId, Severity = "warning", DiagnosticKind = workflowWarning is SubworkflowWarningEvent ? "subworkflow-warning" : "workflow-warning", Message = ResolveDiagnosticMessage(workflowWarning.Data as string, "Workflow warning."), SubworkflowId = workflowWarning is SubworkflowWarningEvent subworkflowWarning ? subworkflowWarning.SubWorkflowId : null, }; return true; case WorkflowErrorEvent workflowError: { Exception? exception = workflowError.Exception; diagnostic = new WorkflowDiagnosticEventDto { Type = "workflow-diagnostic", RequestId = command.RequestId, SessionId = command.SessionId, Severity = "error", DiagnosticKind = workflowError is SubworkflowErrorEvent ? "subworkflow-error" : "workflow-error", Message = ResolveDiagnosticMessage(exception, "Workflow failed."), SubworkflowId = workflowError is SubworkflowErrorEvent subworkflowError ? subworkflowError.SubworkflowId : null, ExceptionType = exception?.GetBaseException().GetType().Name, }; return true; } default: return false; } } private static string ResolveDiagnosticMessage(Exception? exception, string fallback) { return ResolveDiagnosticMessage( exception?.GetBaseException().Message, fallback); } private static string ResolveDiagnosticMessage(string? message, string fallback) { return string.IsNullOrWhiteSpace(message) ? fallback : message; } private static bool IsHandoffFunctionName(string? candidate) { return !string.IsNullOrWhiteSpace(candidate) && candidate.StartsWith(HandoffFunctionPrefix, StringComparison.Ordinal); } private static void TraceHandoff(RunTurnCommandDto command, string message) { if (!string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase)) { return; } Console.Error.WriteLine($"[aryx handoff] {message}"); } }