mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-26 04:43:56 +02:00
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
574 lines
23 KiB
C#
574 lines
23 KiB
C#
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<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
|
RunTurnCommandDto command,
|
|
Func<TurnDeltaEventDto, Task> onDelta,
|
|
Func<SidecarEventDto, Task> onEvent,
|
|
Func<ApprovalRequestedEventDto, Task> onApproval,
|
|
Func<UserInputRequestedEventDto, Task> onUserInput,
|
|
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
|
|
Func<ExitPlanModeRequestedEventDto, Task> 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<ChatMessage> 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<StreamingRun> OpenWorkflowRunAsync(
|
|
RunTurnCommandDto command,
|
|
Workflow workflow,
|
|
IReadOnlyList<ChatMessage> 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<SidecarEventDto, Task> onEvent)
|
|
{
|
|
foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents())
|
|
{
|
|
await onEvent(pendingEvent).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private static async Task EmitPendingMcpOauthRequestsAsync(
|
|
CopilotTurnExecutionState state,
|
|
Func<McpOauthRequiredEventDto, Task> 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<bool> HandleWorkflowEventAsync(
|
|
RunTurnCommandDto command,
|
|
WorkflowEvent evt,
|
|
IReadOnlyList<ChatMessage> inputMessages,
|
|
CopilotTurnExecutionState state,
|
|
Func<TurnDeltaEventDto, Task> onDelta,
|
|
Func<SidecarEventDto, Task> 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<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
|
|
state.UpdateCompletedMessages(allMessages, inputMessages);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static async Task HandleAgentResponseUpdateAsync(
|
|
RunTurnCommandDto command,
|
|
AgentResponseUpdateEvent update,
|
|
CopilotTurnExecutionState state,
|
|
Func<TurnDeltaEventDto, Task> onDelta,
|
|
Func<SidecarEventDto, Task> onEvent)
|
|
{
|
|
AgentIdentity? updateAgent = null;
|
|
string authorName = update.ExecutorId;
|
|
string[] handoffFunctionCalls = update.Update.Contents
|
|
.OfType<FunctionCallContent>()
|
|
.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 ?? "<none>"}'.");
|
|
}
|
|
|
|
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 ?? "<none>"}'.");
|
|
}
|
|
|
|
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<SidecarEventDto, Task> onEvent)
|
|
{
|
|
state.ApplyEvent(activity);
|
|
TraceHandoff(
|
|
command,
|
|
$"Activity emitted: {activity.ActivityType} -> {activity.AgentName ?? activity.AgentId ?? "<unknown>"}.");
|
|
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}");
|
|
}
|
|
}
|