mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-26 04:43:56 +02:00
refactor: generalize sidecar workflow runner
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+5
-3
@@ -141,13 +141,15 @@ Workflows describe how agents collaborate. The architecture supports:
|
|||||||
|
|
||||||
Their runtime semantics follow the Agent Framework orchestration model: sequential and group chat preserve a visible shared conversation, concurrent aggregates multiple independent responses into one turn, and handoff turns can end once the active agent has responded and is waiting for the next user input.
|
Their runtime semantics follow the Agent Framework orchestration model: sequential and group chat preserve a visible shared conversation, concurrent aggregates multiple independent responses into one turn, and handoff turns can end once the active agent has responded and is waiting for the next user input.
|
||||||
|
|
||||||
For Copilot-backed agents, Aryx uses a repo-local adapter around the Copilot SDK session layer so workflow agent routes still behave like Agent Framework handoffs. This is necessary because the upstream `GitHubCopilotAgent` does not currently project run-time handoff tool declarations into Copilot sessions or surface Copilot tool requests back as `FunctionCallContent` for the workflow runtime.
|
For Copilot-backed agents, Aryx uses a repo-local provider module around the Copilot SDK session layer so workflow agent routes still behave like Agent Framework handoffs. This is necessary because the upstream `GitHubCopilotAgent` does not currently project run-time handoff tool declarations into Copilot sessions or surface Copilot tool requests back as `FunctionCallContent` for the workflow runtime.
|
||||||
|
|
||||||
|
The sidecar now keeps that Copilot-specific behavior behind provider seams. Core execution uses shared `IAgentProvider`, `IProviderTurnSupport`, `ProviderSessionEvent`, `ProviderAgentBundle`, `TurnExecutionState`, and `AgentWorkflowTurnRunner` abstractions, while `Services/Providers/Copilot/` owns SDK-specific bundle creation, transcript projection, approvals, user input, MCP OAuth, exit-plan-mode handling, CLI/session management, and event adaptation.
|
||||||
|
|
||||||
Workflows are shared application data, not renderer-only configuration. The same workflow definition now drives validation, persistence, session execution, and sidecar orchestration.
|
Workflows are shared application data, not renderer-only configuration. The same workflow definition now drives validation, persistence, session execution, and sidecar orchestration.
|
||||||
|
|
||||||
Each workflow persists an explicit graph-backed topology. Agent nodes carry stable ids, ordering, and layout metadata, while start/end, fan-out/fan-in, sub-workflow, function, and request-port nodes make execution structure visible in the saved contract.
|
Each workflow persists an explicit graph-backed topology. Agent nodes carry stable ids, ordering, and layout metadata, while start/end, fan-out/fan-in, sub-workflow, function, and request-port nodes make execution structure visible in the saved contract.
|
||||||
|
|
||||||
That graph remains the execution contract for the sidecar, but orchestration mode is now a first-class backend concept. Graph-based modes (`single`, `sequential`, `concurrent`) still execute directly from saved edges. Builder-based modes (`handoff`, `group-chat`) additionally persist mode-specific `settings.modeSettings` data for handoff filtering, triage selection, return behavior, and group-chat round limits, and the sidecar translates those settings into specialized Agent Framework workflow builders at run time.
|
That graph remains the execution contract for the sidecar, but orchestration mode is now a first-class backend concept. Graph-based modes (`single`, `sequential`, `concurrent`) still execute directly from saved edges. Builder-based modes (`handoff`, `group-chat`) additionally persist mode-specific `settings.modeSettings` data for handoff filtering, triage selection, return behavior, and group-chat round limits, and the sidecar translates those settings into specialized Agent Framework workflow builders at run time through shared orchestration helpers rather than Copilot-specific workflow code.
|
||||||
|
|
||||||
Workflow templates remain a first-class shared-domain contract. The shared layer owns workflow definitions, workflow template definitions, and workflow import/export helpers (YAML import/export plus Mermaid and DOT export). Built-in workflows seed workspace state directly, while built-in and custom templates let the main process create additional saved workflows without expanding the sidecar protocol.
|
Workflow templates remain a first-class shared-domain contract. The shared layer owns workflow definitions, workflow template definitions, and workflow import/export helpers (YAML import/export plus Mermaid and DOT export). Built-in workflows seed workspace state directly, while built-in and custom templates let the main process create additional saved workflows without expanding the sidecar protocol.
|
||||||
|
|
||||||
@@ -214,7 +216,7 @@ This is a structured stdio protocol used for:
|
|||||||
- streaming partial output
|
- streaming partial output
|
||||||
- streaming agent activity
|
- streaming agent activity
|
||||||
|
|
||||||
This protocol boundary keeps the AI execution runtime replaceable and prevents the Electron main process from becoming overloaded with workflow-specific behavior.
|
This protocol boundary keeps the AI execution runtime replaceable and prevents the Electron main process from becoming overloaded with workflow-specific behavior. On the sidecar side, raw provider events are first normalized into sidecar-owned provider event records before they become streamed run activity, so future providers can plug into the same transport without reshaping the main-process contract.
|
||||||
|
|
||||||
The protocol also carries **turn-scoped lifecycle events** alongside output deltas. These events let the UI visualize execution internals without the main process having to interpret AI workflow semantics:
|
The protocol also carries **turn-scoped lifecycle events** alongside output deltas. These events let the UI visualize execution internals without the main process having to interpret AI workflow semantics:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace Aryx.AgentHost.Services;
|
||||||
|
|
||||||
|
internal static class AgentHostOptionsFactory
|
||||||
|
{
|
||||||
|
public static AIAgentHostOptions CreateDefault()
|
||||||
|
{
|
||||||
|
return new AIAgentHostOptions
|
||||||
|
{
|
||||||
|
EmitAgentUpdateEvents = null,
|
||||||
|
EmitAgentResponseEvents = false,
|
||||||
|
InterceptUserInputRequests = false,
|
||||||
|
InterceptUnterminatedFunctionCalls = false,
|
||||||
|
ReassignOtherAgentsAsUsers = true,
|
||||||
|
ForwardIncomingMessages = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Aryx.AgentHost.Contracts;
|
||||||
|
using GitHub.Copilot.SDK;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||||
|
using Microsoft.Agents.AI.Workflows.InProc;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Aryx.AgentHost.Services;
|
||||||
|
|
||||||
|
public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
|
||||||
|
{
|
||||||
|
private const string HandoffFunctionPrefix = "handoff_to_";
|
||||||
|
private readonly WorkflowValidator _workflowValidator;
|
||||||
|
private readonly WorkflowRunner _workflowRunner = new();
|
||||||
|
private readonly IProviderTurnSupport _providerTurnSupport;
|
||||||
|
|
||||||
|
internal AgentWorkflowTurnRunner(
|
||||||
|
IProviderTurnSupport providerTurnSupport,
|
||||||
|
WorkflowValidator? workflowValidator = null)
|
||||||
|
{
|
||||||
|
_providerTurnSupport = providerTurnSupport ?? throw new ArgumentNullException(nameof(providerTurnSupport));
|
||||||
|
_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 = _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary)
|
||||||
|
.FirstOrDefault()?.Message;
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(validationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
TurnExecutionState state = new(command);
|
||||||
|
using CancellationTokenSource runCancellation =
|
||||||
|
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
IProviderTranscriptProjector? transcriptProjector = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using ProviderAgentBundle bundle = await _providerTurnSupport.CreateAgentBundleAsync(
|
||||||
|
command,
|
||||||
|
state,
|
||||||
|
onEvent,
|
||||||
|
onApproval,
|
||||||
|
onUserInput,
|
||||||
|
runCancellation,
|
||||||
|
runCancellation.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
transcriptProjector = bundle.TranscriptProjector;
|
||||||
|
ConfigureHookLifecycleEventSuppression(state, bundle);
|
||||||
|
Workflow workflow = BuildWorkflowForCommand(command, bundle.Agents, _workflowRunner);
|
||||||
|
List<ChatMessage> inputMessages = command.Messages.Select(transcriptProjector.ToChatMessage).ToList();
|
||||||
|
transcriptProjector.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))
|
||||||
|
{
|
||||||
|
if (evt is RequestInfoEvent requestInfo
|
||||||
|
&& await TryHandleRequestPortRequestAsync(
|
||||||
|
command,
|
||||||
|
requestInfo,
|
||||||
|
run,
|
||||||
|
onUserInput,
|
||||||
|
runCancellation.Token).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shouldEndTurn = await HandleWorkflowEventAsync(
|
||||||
|
command,
|
||||||
|
evt,
|
||||||
|
inputMessages,
|
||||||
|
state,
|
||||||
|
transcriptProjector,
|
||||||
|
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(transcriptProjector);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
|
||||||
|
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
|
||||||
|
ExitPlanModeRequestedEventDto? exitPlanModeEvent =
|
||||||
|
_providerTurnSupport.ConsumePendingExitPlanModeRequest(command.RequestId);
|
||||||
|
if (exitPlanModeEvent is null || !state.HasPendingExitPlanModeRequest)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onExitPlanMode(exitPlanModeEvent).ConfigureAwait(false);
|
||||||
|
return state.FinalizeCompletedMessages(
|
||||||
|
transcriptProjector ?? throw new InvalidOperationException("Provider transcript projector was not initialized."));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_providerTurnSupport.ClearRequestState(command.RequestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Workflow BuildWorkflowForCommand(
|
||||||
|
RunTurnCommandDto command,
|
||||||
|
IReadOnlyList<AIAgent> agents,
|
||||||
|
WorkflowRunner? workflowRunner = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
ArgumentNullException.ThrowIfNull(agents);
|
||||||
|
|
||||||
|
return NormalizeOrchestrationMode(command.Workflow.Settings.OrchestrationMode) switch
|
||||||
|
{
|
||||||
|
"handoff" => WorkflowOrchestrationFactory.CreateHandoffWorkflow(command.Workflow, agents),
|
||||||
|
"group-chat" => WorkflowOrchestrationFactory.CreateGroupChatWorkflow(command.Workflow, agents),
|
||||||
|
_ => (workflowRunner ?? new WorkflowRunner()).BuildWorkflow(command.Workflow, agents, command.WorkflowLibrary),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
return command.Workflow.Settings.Checkpointing.Enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 string? NormalizeOrchestrationMode(string? value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ValueTask<StreamingRun> OpenWorkflowRunAsync(
|
||||||
|
RunTurnCommandDto command,
|
||||||
|
Workflow workflow,
|
||||||
|
IReadOnlyList<ChatMessage> inputMessages,
|
||||||
|
CheckpointManager? checkpointManager)
|
||||||
|
{
|
||||||
|
InProcessExecutionEnvironment environment = CreateExecutionEnvironment(command, checkpointManager);
|
||||||
|
if (checkpointManager is not null && command.ResumeFromCheckpoint is { } resumeFromCheckpoint)
|
||||||
|
{
|
||||||
|
return environment.ResumeStreamingAsync(
|
||||||
|
workflow,
|
||||||
|
new CheckpointInfo(resumeFromCheckpoint.WorkflowSessionId, resumeFromCheckpoint.CheckpointId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return environment.RunStreamingAsync(workflow, inputMessages.ToList(), command.RequestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static InProcessExecutionEnvironment CreateExecutionEnvironment(
|
||||||
|
RunTurnCommandDto command,
|
||||||
|
CheckpointManager? checkpointManager)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
|
||||||
|
string executionMode = command.Workflow.Settings.ExecutionMode?.Trim() ?? "off-thread";
|
||||||
|
InProcessExecutionEnvironment environment = string.Equals(
|
||||||
|
executionMode,
|
||||||
|
"lockstep",
|
||||||
|
StringComparison.OrdinalIgnoreCase)
|
||||||
|
? InProcessExecution.Lockstep
|
||||||
|
: InProcessExecution.OffThread;
|
||||||
|
|
||||||
|
return checkpointManager is null ? environment : environment.WithCheckpointing(checkpointManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void ConfigureHookLifecycleEventSuppression(
|
||||||
|
TurnExecutionState state,
|
||||||
|
ProviderAgentBundle bundle)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(state);
|
||||||
|
ArgumentNullException.ThrowIfNull(bundle);
|
||||||
|
|
||||||
|
state.SuppressHookLifecycleEvents = !bundle.HasConfiguredHooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EmitPendingEventsAsync(
|
||||||
|
TurnExecutionState state,
|
||||||
|
Func<SidecarEventDto, Task> onEvent)
|
||||||
|
{
|
||||||
|
foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents())
|
||||||
|
{
|
||||||
|
await onEvent(pendingEvent).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EmitPendingMcpOauthRequestsAsync(
|
||||||
|
TurnExecutionState state,
|
||||||
|
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired)
|
||||||
|
{
|
||||||
|
foreach (McpOauthRequiredEventDto request in state.DrainPendingMcpOauthRequests())
|
||||||
|
{
|
||||||
|
await onMcpOAuthRequired(request).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ResolveApprovalAsync(
|
||||||
|
ResolveApprovalCommandDto command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _providerTurnSupport.ResolveApprovalAsync(command, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ResolveUserInputAsync(
|
||||||
|
ResolveUserInputCommandDto command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _providerTurnSupport.ResolveUserInputAsync(command, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryHandleRequestPortRequestAsync(
|
||||||
|
RunTurnCommandDto command,
|
||||||
|
RequestInfoEvent requestInfo,
|
||||||
|
StreamingRun run,
|
||||||
|
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryResolveRequestPortMetadata(command.Workflow, requestInfo, out WorkflowRequestPortMetadata? metadata))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
UserInputRequest userInputRequest = CreateRequestPortUserInputRequest(metadata!, requestInfo);
|
||||||
|
UserInputResponse response = await _providerTurnSupport.RequestRequestPortUserInputAsync(
|
||||||
|
command,
|
||||||
|
userInputRequest,
|
||||||
|
onUserInput,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
object coercedResponse = CoerceRequestPortResponse(metadata!.ResponseType, response.Answer);
|
||||||
|
await run.SendResponseAsync(requestInfo.Request.CreateResponse(coercedResponse)).ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task<bool> HandleWorkflowEventAsync(
|
||||||
|
RunTurnCommandDto command,
|
||||||
|
WorkflowEvent evt,
|
||||||
|
IReadOnlyList<ChatMessage> inputMessages,
|
||||||
|
TurnExecutionState state,
|
||||||
|
IProviderTranscriptProjector transcriptProjector,
|
||||||
|
Func<TurnDeltaEventDto, Task> onDelta,
|
||||||
|
Func<SidecarEventDto, Task> onEvent)
|
||||||
|
{
|
||||||
|
if (evt is ExecutorInvokedEvent invoked)
|
||||||
|
{
|
||||||
|
if (AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||||
|
command.Workflow,
|
||||||
|
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.Workflow,
|
||||||
|
completed.ExecutorId,
|
||||||
|
state.ActiveAgent,
|
||||||
|
out AgentIdentity completedAgent))
|
||||||
|
{
|
||||||
|
TraceHandoff(command, $"Executor completed: {completed.ExecutorId} -> {completedAgent.AgentName} ({completedAgent.AgentId}).");
|
||||||
|
state.QueueCompletedActivity(completedAgent);
|
||||||
|
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, transcriptProjector);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static UserInputRequest CreateRequestPortUserInputRequest(
|
||||||
|
WorkflowRequestPortMetadata metadata,
|
||||||
|
RequestInfoEvent requestInfo)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(metadata);
|
||||||
|
ArgumentNullException.ThrowIfNull(requestInfo);
|
||||||
|
|
||||||
|
string question = metadata.Prompt
|
||||||
|
?? BuildRequestPortFallbackQuestion(metadata, requestInfo);
|
||||||
|
|
||||||
|
bool expectsBoolean = IsBooleanResponseType(metadata.ResponseType);
|
||||||
|
return new UserInputRequest
|
||||||
|
{
|
||||||
|
Question = question,
|
||||||
|
Choices = expectsBoolean ? ["true", "false"] : null,
|
||||||
|
AllowFreeform = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static object CoerceRequestPortResponse(string responseType, string? answer)
|
||||||
|
{
|
||||||
|
string normalizedResponseType = responseType.Trim();
|
||||||
|
string trimmedAnswer = answer?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
if (IsStringResponseType(normalizedResponseType))
|
||||||
|
{
|
||||||
|
return trimmedAnswer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsBooleanResponseType(normalizedResponseType))
|
||||||
|
{
|
||||||
|
return trimmedAnswer.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"true" or "t" or "yes" or "y" or "1" => true,
|
||||||
|
"false" or "f" or "no" or "n" or "0" => false,
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
$"Request port response type \"{responseType}\" requires a boolean answer."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsNumericResponseType(normalizedResponseType))
|
||||||
|
{
|
||||||
|
if (double.TryParse(trimmedAnswer, NumberStyles.Float, CultureInfo.InvariantCulture, out double numeric))
|
||||||
|
{
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Request port response type \"{responseType}\" requires a numeric answer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsJsonResponseType(normalizedResponseType))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonDocument.Parse(trimmedAnswer).RootElement.Clone();
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Request port response type \"{responseType}\" requires a valid JSON answer.",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedAnswer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleAgentResponseUpdateAsync(
|
||||||
|
RunTurnCommandDto command,
|
||||||
|
AgentResponseUpdateEvent update,
|
||||||
|
TurnExecutionState 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.Workflow,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task EmitActivityAsync(
|
||||||
|
RunTurnCommandDto command,
|
||||||
|
TurnExecutionState 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,
|
||||||
|
TurnExecutionState state,
|
||||||
|
out WorkflowDiagnosticEventDto diagnostic)
|
||||||
|
{
|
||||||
|
diagnostic = default!;
|
||||||
|
|
||||||
|
switch (evt)
|
||||||
|
{
|
||||||
|
case ExecutorFailedEvent executorFailed:
|
||||||
|
{
|
||||||
|
AgentIdentity? agent = AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||||
|
command.Workflow,
|
||||||
|
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 bool TryResolveRequestPortMetadata(
|
||||||
|
WorkflowDefinitionDto? workflow,
|
||||||
|
RequestInfoEvent requestInfo,
|
||||||
|
out WorkflowRequestPortMetadata? metadata)
|
||||||
|
{
|
||||||
|
metadata = null;
|
||||||
|
if (workflow is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string portId = requestInfo.Request.PortInfo.PortId;
|
||||||
|
WorkflowNodeDto? node = workflow.Graph.Nodes.FirstOrDefault(candidate =>
|
||||||
|
string.Equals(candidate.Kind, "request-port", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(candidate.Config.PortId, portId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (node is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = new WorkflowRequestPortMetadata(
|
||||||
|
node.Id,
|
||||||
|
string.IsNullOrWhiteSpace(node.Label) ? node.Id : node.Label,
|
||||||
|
node.Config.PortId ?? portId,
|
||||||
|
node.Config.RequestType ?? string.Empty,
|
||||||
|
node.Config.ResponseType ?? string.Empty,
|
||||||
|
string.IsNullOrWhiteSpace(node.Config.Prompt) ? null : node.Config.Prompt.Trim());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildRequestPortFallbackQuestion(
|
||||||
|
WorkflowRequestPortMetadata metadata,
|
||||||
|
RequestInfoEvent requestInfo)
|
||||||
|
{
|
||||||
|
if (requestInfo.Request.Data.Is<WorkflowRequestPortPromptRequest>(out WorkflowRequestPortPromptRequest? promptRequest))
|
||||||
|
{
|
||||||
|
string baseQuestion = $"Provide a {metadata.ResponseType} response for \"{promptRequest.NodeLabel}\".";
|
||||||
|
if (!string.IsNullOrWhiteSpace(promptRequest.InputSummary))
|
||||||
|
{
|
||||||
|
return $"{baseQuestion} Current input: {promptRequest.InputSummary}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseQuestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Provide a {metadata.ResponseType} response for request port \"{metadata.NodeLabel}\" ({metadata.PortId}).";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsStringResponseType(string responseType)
|
||||||
|
=> string.Equals(responseType, "string", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(responseType, "text", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool IsBooleanResponseType(string responseType)
|
||||||
|
=> string.Equals(responseType, "bool", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(responseType, "boolean", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool IsNumericResponseType(string responseType)
|
||||||
|
=> string.Equals(responseType, "number", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(responseType, "int", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(responseType, "float", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(responseType, "double", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(responseType, "decimal", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool IsJsonResponseType(string responseType)
|
||||||
|
=> string.Equals(responseType, "json", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(responseType, "object", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(responseType, "array", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
internal sealed record WorkflowRequestPortMetadata(
|
||||||
|
string NodeId,
|
||||||
|
string NodeLabel,
|
||||||
|
string PortId,
|
||||||
|
string RequestType,
|
||||||
|
string ResponseType,
|
||||||
|
string? Prompt);
|
||||||
|
|
||||||
|
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 (!command.Workflow.IsOrchestrationMode("handoff"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine($"[aryx handoff] {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,669 +1,24 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using Aryx.AgentHost.Contracts;
|
using Aryx.AgentHost.Contracts;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
namespace Aryx.AgentHost.Services;
|
namespace Aryx.AgentHost.Services;
|
||||||
|
|
||||||
internal sealed class CopilotTurnExecutionState
|
internal sealed class CopilotTurnExecutionState : TurnExecutionState
|
||||||
{
|
{
|
||||||
private readonly RunTurnCommandDto _command;
|
|
||||||
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
private readonly HashSet<string> _reclassifiedMessageIds = new(StringComparer.Ordinal);
|
|
||||||
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
|
|
||||||
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
|
|
||||||
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
|
|
||||||
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
|
|
||||||
private int _fallbackMessageIndex;
|
|
||||||
private string? _lastObservedMessageId;
|
|
||||||
|
|
||||||
public CopilotTurnExecutionState(RunTurnCommandDto command)
|
public CopilotTurnExecutionState(RunTurnCommandDto command)
|
||||||
|
: base(command)
|
||||||
{
|
{
|
||||||
_command = command;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ConcurrentDictionary<string, string> ToolNamesByCallId { get; } = new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
public AgentIdentity? ActiveAgent { get; private set; }
|
|
||||||
|
|
||||||
public List<ChatMessageDto> CompletedMessages { get; private set; } = [];
|
|
||||||
|
|
||||||
public bool HasPendingExitPlanModeRequest { get; private set; }
|
|
||||||
|
|
||||||
public bool SuppressHookLifecycleEvents { get; set; }
|
|
||||||
|
|
||||||
public async Task EmitThinkingIfNeeded(
|
|
||||||
AgentIdentity agent,
|
|
||||||
Func<SidecarEventDto, Task> onEvent)
|
|
||||||
{
|
|
||||||
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
|
||||||
if (thinkingActivity is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await onEvent(thinkingActivity).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void QueueThinkingIfNeeded(AgentIdentity agent)
|
|
||||||
{
|
|
||||||
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
|
||||||
if (thinkingActivity is not null)
|
|
||||||
{
|
|
||||||
_pendingEvents.Enqueue(thinkingActivity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void QueueCompletedActivity(AgentIdentity agent)
|
|
||||||
{
|
|
||||||
_pendingEvents.Enqueue(CreateCompletedActivity(agent));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ApplyEvent(SidecarEventDto evt)
|
|
||||||
{
|
|
||||||
if (evt is AgentActivityEventDto activity
|
|
||||||
&& string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
|
||||||
&& !string.IsNullOrWhiteSpace(activity.AgentId)
|
|
||||||
&& !string.IsNullOrWhiteSpace(activity.AgentName))
|
|
||||||
{
|
|
||||||
ActiveAgent = new AgentIdentity(activity.AgentId, activity.AgentName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, ProviderSessionEvent sessionEvent)
|
|
||||||
{
|
|
||||||
AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity(
|
|
||||||
_command.Workflow,
|
|
||||||
agentDefinition.GetAgentId(),
|
|
||||||
agentDefinition.GetAgentName());
|
|
||||||
|
|
||||||
switch (sessionEvent)
|
|
||||||
{
|
|
||||||
case ProviderAssistantMessageDeltaEvent messageDelta:
|
|
||||||
RecordObservedAgentForMessage(agent, messageDelta.MessageId);
|
|
||||||
QueueThinkingIfNeeded(agent);
|
|
||||||
break;
|
|
||||||
case ProviderAssistantMessageEvent assistantMessage:
|
|
||||||
RecordObservedAgentForMessage(agent, assistantMessage.MessageId);
|
|
||||||
QueueThinkingIfNeeded(agent);
|
|
||||||
if (assistantMessage.HasToolRequests)
|
|
||||||
{
|
|
||||||
QueueMessageReclassifiedIfNeeded(assistantMessage.MessageId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case ProviderToolExecutionStartEvent toolExecutionStart:
|
|
||||||
string toolCallId = toolExecutionStart.ToolCallId;
|
|
||||||
string toolName = toolExecutionStart.ToolName;
|
|
||||||
ToolNamesByCallId[toolCallId] = toolName;
|
|
||||||
ActiveAgent = agent;
|
|
||||||
AgentActivityEventDto? toolActivity = CreateToolCallingActivity(
|
|
||||||
agent, toolName, toolCallId, toolExecutionStart.ToolArguments);
|
|
||||||
if (toolActivity is not null)
|
|
||||||
{
|
|
||||||
_pendingEvents.Enqueue(toolActivity);
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
|
|
||||||
break;
|
|
||||||
case ProviderAssistantIntentEvent intentEvent:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
QueueThinkingIfNeeded(agent);
|
|
||||||
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Intent);
|
|
||||||
if (assistantIntent is not null)
|
|
||||||
{
|
|
||||||
_pendingEvents.Enqueue(assistantIntent);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case ProviderAssistantReasoningDeltaEvent reasoningDelta:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
QueueThinkingIfNeeded(agent);
|
|
||||||
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(
|
|
||||||
agent,
|
|
||||||
reasoningDelta.ReasoningId,
|
|
||||||
reasoningDelta.DeltaContent);
|
|
||||||
if (reasoningDeltaEvent is not null)
|
|
||||||
{
|
|
||||||
_pendingEvents.Enqueue(reasoningDeltaEvent);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case ProviderSubagentStartedEvent started:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
_pendingEvents.Enqueue(CreateSubagentStartedEvent(agent, started));
|
|
||||||
break;
|
|
||||||
case ProviderSubagentCompletedEvent completed:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
_pendingEvents.Enqueue(CreateSubagentCompletedEvent(agent, completed));
|
|
||||||
break;
|
|
||||||
case ProviderSubagentFailedEvent failed:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
_pendingEvents.Enqueue(CreateSubagentFailedEvent(agent, failed));
|
|
||||||
break;
|
|
||||||
case ProviderSubagentSelectedEvent selected:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
_pendingEvents.Enqueue(CreateSubagentSelectedEvent(agent, selected));
|
|
||||||
break;
|
|
||||||
case ProviderSubagentDeselectedEvent:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
_pendingEvents.Enqueue(CreateSubagentDeselectedEvent(agent));
|
|
||||||
break;
|
|
||||||
case ProviderSkillInvokedEvent skillInvoked:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
_pendingEvents.Enqueue(CreateSkillInvokedEvent(agent, skillInvoked));
|
|
||||||
break;
|
|
||||||
case ProviderHookStartEvent hookStart:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
if (!SuppressHookLifecycleEvents)
|
|
||||||
{
|
|
||||||
_pendingEvents.Enqueue(CreateHookLifecycleEvent(
|
|
||||||
agent,
|
|
||||||
"start",
|
|
||||||
hookStart.HookInvocationId,
|
|
||||||
hookStart.HookType,
|
|
||||||
input: hookStart.Input));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case ProviderHookEndEvent hookEnd:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
if (!SuppressHookLifecycleEvents)
|
|
||||||
{
|
|
||||||
_pendingEvents.Enqueue(CreateHookLifecycleEvent(
|
|
||||||
agent,
|
|
||||||
"end",
|
|
||||||
hookEnd.HookInvocationId,
|
|
||||||
hookEnd.HookType,
|
|
||||||
success: hookEnd.Success,
|
|
||||||
output: hookEnd.Output,
|
|
||||||
error: hookEnd.Error));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case ProviderAssistantUsageEvent assistantUsage:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
_pendingEvents.Enqueue(CreateAssistantUsageEvent(agent, assistantUsage));
|
|
||||||
break;
|
|
||||||
case ProviderSessionUsageEvent usageInfo:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
_pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo));
|
|
||||||
break;
|
|
||||||
case ProviderSessionCompactionStartEvent compactionStart:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
_pendingEvents.Enqueue(CreateCompactionStartEvent(agent, compactionStart));
|
|
||||||
break;
|
|
||||||
case ProviderSessionCompactionCompleteEvent compactionComplete:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
_pendingEvents.Enqueue(CreateCompactionCompleteEvent(agent, compactionComplete));
|
|
||||||
break;
|
|
||||||
case ProviderPendingMessagesModifiedEvent:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
_pendingEvents.Enqueue(CreatePendingMessagesModifiedEvent(agent));
|
|
||||||
break;
|
|
||||||
case ProviderMcpOauthRequiredEvent:
|
|
||||||
ActiveAgent = agent;
|
|
||||||
break;
|
|
||||||
case ProviderExitPlanModeRequestedEvent:
|
|
||||||
HasPendingExitPlanModeRequest = true;
|
|
||||||
ActiveAgent = agent;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<SidecarEventDto> DrainPendingEvents()
|
|
||||||
{
|
|
||||||
List<SidecarEventDto> pending = [];
|
|
||||||
while (_pendingEvents.TryDequeue(out SidecarEventDto? pendingEvent))
|
|
||||||
{
|
|
||||||
pending.Add(pendingEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void EnqueuePendingMcpOauthRequest(McpOauthRequiredEventDto request)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
|
||||||
_pendingMcpOauthRequests.Enqueue(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<McpOauthRequiredEventDto> DrainPendingMcpOauthRequests()
|
|
||||||
{
|
|
||||||
List<McpOauthRequiredEventDto> pending = [];
|
|
||||||
while (_pendingMcpOauthRequests.TryDequeue(out McpOauthRequiredEventDto? request))
|
|
||||||
{
|
|
||||||
pending.Add(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
|
|
||||||
{
|
|
||||||
agent = default;
|
|
||||||
return !string.IsNullOrWhiteSpace(messageId)
|
|
||||||
&& _observedAgentsByMessageId.TryGetValue(messageId, out agent);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string CreateMessageId(string? messageId)
|
|
||||||
{
|
|
||||||
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public TranscriptSegment AppendDelta(
|
|
||||||
string messageId,
|
|
||||||
string authorName,
|
|
||||||
string delta)
|
|
||||||
{
|
|
||||||
return _transcriptBuffer.AppendDelta(messageId, authorName, delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ClearActiveAgentIfMatching(AgentIdentity completedAgent)
|
|
||||||
{
|
|
||||||
if (ActiveAgent.HasValue
|
|
||||||
&& string.Equals(ActiveAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
ActiveAgent = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RecordObservedAgentForMessage(AgentIdentity agent, string messageId)
|
|
||||||
{
|
|
||||||
ActiveAgent = agent;
|
|
||||||
_observedAgentsByMessageId[messageId] = agent;
|
|
||||||
_lastObservedMessageId = messageId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void QueueMessageReclassifiedIfNeeded(string? messageId)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(messageId))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
string normalizedMessageId = messageId.Trim();
|
|
||||||
if (!_reclassifiedMessageIds.Add(normalizedMessageId))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_pendingEvents.Enqueue(CreateMessageReclassifiedEvent(normalizedMessageId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private AgentActivityEventDto? CreateThinkingActivityIfNeeded(AgentIdentity agent)
|
|
||||||
{
|
|
||||||
ActiveAgent = agent;
|
|
||||||
|
|
||||||
if (!_startedAgents.Add(agent.AgentId))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AgentActivityEventDto
|
|
||||||
{
|
|
||||||
Type = "agent-activity",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
ActivityType = "thinking",
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private AgentActivityEventDto? CreateToolCallingActivity(
|
|
||||||
AgentIdentity agent,
|
|
||||||
string toolName,
|
|
||||||
string toolCallId,
|
|
||||||
IReadOnlyDictionary<string, object?>? toolArguments = null)
|
|
||||||
{
|
|
||||||
if (toolName.StartsWith("handoff_to_", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AgentActivityEventDto
|
|
||||||
{
|
|
||||||
Type = "agent-activity",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
ActivityType = "tool-calling",
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
ToolName = toolName,
|
|
||||||
ToolCallId = toolCallId,
|
|
||||||
ToolArguments = toolArguments,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private AgentActivityEventDto CreateCompletedActivity(AgentIdentity agent)
|
|
||||||
{
|
|
||||||
return new AgentActivityEventDto
|
|
||||||
{
|
|
||||||
Type = "agent-activity",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
ActivityType = "completed",
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId)
|
|
||||||
{
|
|
||||||
return new MessageReclassifiedEventDto
|
|
||||||
{
|
|
||||||
Type = "message-reclassified",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
MessageId = messageId,
|
|
||||||
NewKind = "thinking",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateCompletedMessages(
|
public void UpdateCompletedMessages(
|
||||||
IReadOnlyList<ChatMessage> allMessages,
|
IReadOnlyList<ChatMessage> allMessages,
|
||||||
IReadOnlyList<ChatMessage> inputMessages)
|
IReadOnlyList<ChatMessage> inputMessages)
|
||||||
{
|
{
|
||||||
List<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(allMessages, inputMessages);
|
base.UpdateCompletedMessages(allMessages, inputMessages, CopilotTranscriptProjector.Instance);
|
||||||
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
|
|
||||||
_command,
|
|
||||||
newMessages,
|
|
||||||
_transcriptBuffer.Snapshot(),
|
|
||||||
ActiveAgent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<ChatMessageDto> FinalizeCompletedMessages()
|
public IReadOnlyList<ChatMessageDto> FinalizeCompletedMessages()
|
||||||
{
|
{
|
||||||
if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0)
|
return base.FinalizeCompletedMessages(CopilotTranscriptProjector.Instance);
|
||||||
{
|
|
||||||
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
|
|
||||||
_command,
|
|
||||||
[],
|
|
||||||
_transcriptBuffer.Snapshot(),
|
|
||||||
ActiveAgent);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (ChatMessageDto message in CompletedMessages)
|
|
||||||
{
|
|
||||||
if (_reclassifiedMessageIds.Contains(message.Id))
|
|
||||||
{
|
|
||||||
message.MessageKind = "thinking";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return CompletedMessages;
|
|
||||||
}
|
|
||||||
|
|
||||||
private SubagentEventDto CreateSubagentStartedEvent(
|
|
||||||
AgentIdentity agent,
|
|
||||||
ProviderSubagentStartedEvent data)
|
|
||||||
{
|
|
||||||
return new SubagentEventDto
|
|
||||||
{
|
|
||||||
Type = "subagent-event",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
EventKind = "started",
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
ToolCallId = data.ToolCallId,
|
|
||||||
CustomAgentName = data.AgentName,
|
|
||||||
CustomAgentDisplayName = data.AgentDisplayName,
|
|
||||||
CustomAgentDescription = data.AgentDescription,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private SubagentEventDto CreateSubagentCompletedEvent(
|
|
||||||
AgentIdentity agent,
|
|
||||||
ProviderSubagentCompletedEvent data)
|
|
||||||
{
|
|
||||||
return new SubagentEventDto
|
|
||||||
{
|
|
||||||
Type = "subagent-event",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
EventKind = "completed",
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
ToolCallId = data.ToolCallId,
|
|
||||||
CustomAgentName = data.AgentName,
|
|
||||||
CustomAgentDisplayName = data.AgentDisplayName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private SubagentEventDto CreateSubagentFailedEvent(
|
|
||||||
AgentIdentity agent,
|
|
||||||
ProviderSubagentFailedEvent data)
|
|
||||||
{
|
|
||||||
return new SubagentEventDto
|
|
||||||
{
|
|
||||||
Type = "subagent-event",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
EventKind = "failed",
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
ToolCallId = data.ToolCallId,
|
|
||||||
CustomAgentName = data.AgentName,
|
|
||||||
CustomAgentDisplayName = data.AgentDisplayName,
|
|
||||||
Error = data.Error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private SubagentEventDto CreateSubagentSelectedEvent(
|
|
||||||
AgentIdentity agent,
|
|
||||||
ProviderSubagentSelectedEvent data)
|
|
||||||
{
|
|
||||||
return new SubagentEventDto
|
|
||||||
{
|
|
||||||
Type = "subagent-event",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
EventKind = "selected",
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
CustomAgentName = data.AgentName,
|
|
||||||
CustomAgentDisplayName = data.AgentDisplayName,
|
|
||||||
Tools = data.Tools,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private SubagentEventDto CreateSubagentDeselectedEvent(AgentIdentity agent)
|
|
||||||
{
|
|
||||||
return new SubagentEventDto
|
|
||||||
{
|
|
||||||
Type = "subagent-event",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
EventKind = "deselected",
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private AssistantIntentEventDto? CreateAssistantIntentEvent(
|
|
||||||
AgentIdentity agent,
|
|
||||||
string? intent)
|
|
||||||
{
|
|
||||||
string? normalizedIntent = intent?.Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(normalizedIntent))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AssistantIntentEventDto
|
|
||||||
{
|
|
||||||
Type = "assistant-intent",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
Intent = normalizedIntent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private ReasoningDeltaEventDto? CreateReasoningDeltaEvent(
|
|
||||||
AgentIdentity agent,
|
|
||||||
string? reasoningId,
|
|
||||||
string? deltaContent)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(reasoningId)
|
|
||||||
|| string.IsNullOrEmpty(deltaContent))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ReasoningDeltaEventDto
|
|
||||||
{
|
|
||||||
Type = "reasoning-delta",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
ReasoningId = reasoningId,
|
|
||||||
ContentDelta = deltaContent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private SkillInvokedEventDto CreateSkillInvokedEvent(
|
|
||||||
AgentIdentity agent,
|
|
||||||
ProviderSkillInvokedEvent data)
|
|
||||||
{
|
|
||||||
return new SkillInvokedEventDto
|
|
||||||
{
|
|
||||||
Type = "skill-invoked",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
SkillName = data.SkillName,
|
|
||||||
Path = data.Path,
|
|
||||||
Content = data.Content,
|
|
||||||
AllowedTools = data.AllowedTools,
|
|
||||||
PluginName = data.PluginName,
|
|
||||||
PluginVersion = data.PluginVersion,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private HookLifecycleEventDto CreateHookLifecycleEvent(
|
|
||||||
AgentIdentity agent,
|
|
||||||
string phase,
|
|
||||||
string hookInvocationId,
|
|
||||||
string hookType,
|
|
||||||
object? input = null,
|
|
||||||
bool? success = null,
|
|
||||||
object? output = null,
|
|
||||||
string? error = null)
|
|
||||||
{
|
|
||||||
return new HookLifecycleEventDto
|
|
||||||
{
|
|
||||||
Type = "hook-lifecycle",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
HookInvocationId = hookInvocationId,
|
|
||||||
HookType = hookType,
|
|
||||||
Phase = phase,
|
|
||||||
Input = input,
|
|
||||||
Success = success,
|
|
||||||
Output = output,
|
|
||||||
Error = error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private AssistantUsageEventDto CreateAssistantUsageEvent(
|
|
||||||
AgentIdentity agent,
|
|
||||||
ProviderAssistantUsageEvent data)
|
|
||||||
{
|
|
||||||
return new AssistantUsageEventDto
|
|
||||||
{
|
|
||||||
Type = "assistant-usage",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
Model = data.Model,
|
|
||||||
InputTokens = data.InputTokens,
|
|
||||||
OutputTokens = data.OutputTokens,
|
|
||||||
CacheReadTokens = data.CacheReadTokens,
|
|
||||||
CacheWriteTokens = data.CacheWriteTokens,
|
|
||||||
Cost = data.Cost,
|
|
||||||
Duration = data.Duration,
|
|
||||||
TotalNanoAiu = data.TotalNanoAiu,
|
|
||||||
QuotaSnapshots = data.QuotaSnapshots,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, ProviderSessionUsageEvent data)
|
|
||||||
{
|
|
||||||
return new SessionUsageEventDto
|
|
||||||
{
|
|
||||||
Type = "session-usage",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
TokenLimit = data.TokenLimit,
|
|
||||||
CurrentTokens = data.CurrentTokens,
|
|
||||||
MessagesLength = data.MessagesLength,
|
|
||||||
SystemTokens = data.SystemTokens,
|
|
||||||
ConversationTokens = data.ConversationTokens,
|
|
||||||
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
|
|
||||||
IsInitial = data.IsInitial,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private SessionCompactionEventDto CreateCompactionStartEvent(
|
|
||||||
AgentIdentity agent,
|
|
||||||
ProviderSessionCompactionStartEvent data)
|
|
||||||
{
|
|
||||||
return new SessionCompactionEventDto
|
|
||||||
{
|
|
||||||
Type = "session-compaction",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
Phase = "start",
|
|
||||||
SystemTokens = data.SystemTokens,
|
|
||||||
ConversationTokens = data.ConversationTokens,
|
|
||||||
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private SessionCompactionEventDto CreateCompactionCompleteEvent(
|
|
||||||
AgentIdentity agent,
|
|
||||||
ProviderSessionCompactionCompleteEvent data)
|
|
||||||
{
|
|
||||||
return new SessionCompactionEventDto
|
|
||||||
{
|
|
||||||
Type = "session-compaction",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
Phase = "complete",
|
|
||||||
Success = data.Success,
|
|
||||||
Error = data.Error,
|
|
||||||
SystemTokens = data.SystemTokens,
|
|
||||||
ConversationTokens = data.ConversationTokens,
|
|
||||||
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
|
|
||||||
PreCompactionTokens = data.PreCompactionTokens,
|
|
||||||
PostCompactionTokens = data.PostCompactionTokens,
|
|
||||||
PreCompactionMessagesLength = data.PreCompactionMessagesLength,
|
|
||||||
MessagesRemoved = data.MessagesRemoved,
|
|
||||||
TokensRemoved = data.TokensRemoved,
|
|
||||||
SummaryContent = data.SummaryContent,
|
|
||||||
CheckpointNumber = data.CheckpointNumber,
|
|
||||||
CheckpointPath = data.CheckpointPath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private PendingMessagesModifiedEventDto CreatePendingMessagesModifiedEvent(AgentIdentity agent)
|
|
||||||
{
|
|
||||||
return new PendingMessagesModifiedEventDto
|
|
||||||
{
|
|
||||||
Type = "pending-messages-modified",
|
|
||||||
RequestId = _command.RequestId,
|
|
||||||
SessionId = _command.SessionId,
|
|
||||||
AgentId = agent.AgentId,
|
|
||||||
AgentName = agent.AgentName,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Aryx.AgentHost.Contracts;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Aryx.AgentHost.Services;
|
||||||
|
|
||||||
|
internal interface IProviderTranscriptProjector
|
||||||
|
{
|
||||||
|
ChatMessage ToChatMessage(ChatMessageDto message);
|
||||||
|
|
||||||
|
void AttachMessageMode(IList<ChatMessage> messages, string? messageMode);
|
||||||
|
|
||||||
|
List<ChatMessage> SelectNewOutputMessages(
|
||||||
|
IReadOnlyList<ChatMessage> outputMessages,
|
||||||
|
IReadOnlyList<ChatMessage> inputMessages);
|
||||||
|
|
||||||
|
List<ChatMessageDto> ProjectCompletedMessagesFromSegments(
|
||||||
|
RunTurnCommandDto command,
|
||||||
|
IReadOnlyList<ChatMessage> newMessages,
|
||||||
|
IReadOnlyList<TranscriptSegment> segments,
|
||||||
|
AgentIdentity? fallbackAgent = null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Aryx.AgentHost.Contracts;
|
||||||
|
using GitHub.Copilot.SDK;
|
||||||
|
|
||||||
|
namespace Aryx.AgentHost.Services;
|
||||||
|
|
||||||
|
internal interface IProviderTurnSupport
|
||||||
|
{
|
||||||
|
Task<ProviderAgentBundle> CreateAgentBundleAsync(
|
||||||
|
RunTurnCommandDto command,
|
||||||
|
TurnExecutionState state,
|
||||||
|
Func<SidecarEventDto, Task> onEvent,
|
||||||
|
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||||
|
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||||
|
CancellationTokenSource runCancellation,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task ResolveApprovalAsync(
|
||||||
|
ResolveApprovalCommandDto command,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task ResolveUserInputAsync(
|
||||||
|
ResolveUserInputCommandDto command,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<UserInputResponse> RequestRequestPortUserInputAsync(
|
||||||
|
RunTurnCommandDto command,
|
||||||
|
UserInputRequest request,
|
||||||
|
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
ExitPlanModeRequestedEventDto? ConsumePendingExitPlanModeRequest(string requestId);
|
||||||
|
|
||||||
|
void ClearRequestState(string requestId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Microsoft.Agents.AI;
|
||||||
|
|
||||||
|
namespace Aryx.AgentHost.Services;
|
||||||
|
|
||||||
|
internal abstract class ProviderAgentBundle : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public abstract IReadOnlyList<AIAgent> Agents { get; }
|
||||||
|
|
||||||
|
public abstract bool HasConfiguredHooks { get; }
|
||||||
|
|
||||||
|
public abstract IProviderTranscriptProjector TranscriptProjector { get; }
|
||||||
|
|
||||||
|
public abstract ValueTask DisposeAsync();
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ using Microsoft.Extensions.AI;
|
|||||||
|
|
||||||
namespace Aryx.AgentHost.Services;
|
namespace Aryx.AgentHost.Services;
|
||||||
|
|
||||||
internal sealed class CopilotAgentBundle : IAsyncDisposable
|
internal sealed class CopilotAgentBundle : ProviderAgentBundle
|
||||||
{
|
{
|
||||||
private static readonly string[] RequiredPromptTools =
|
private static readonly string[] RequiredPromptTools =
|
||||||
[
|
[
|
||||||
@@ -25,9 +25,11 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
HasConfiguredHooks = hasConfiguredHooks;
|
HasConfiguredHooks = hasConfiguredHooks;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<AIAgent> Agents { get; }
|
public override IReadOnlyList<AIAgent> Agents { get; }
|
||||||
|
|
||||||
public bool HasConfiguredHooks { get; }
|
public override bool HasConfiguredHooks { get; }
|
||||||
|
|
||||||
|
public override IProviderTranscriptProjector TranscriptProjector { get; } = CopilotTranscriptProjector.Instance;
|
||||||
|
|
||||||
public static async Task<CopilotAgentBundle> CreateAsync(
|
public static async Task<CopilotAgentBundle> CreateAsync(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
@@ -215,116 +217,38 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
|
|
||||||
internal static AIAgentHostOptions CreateAgentHostOptions()
|
internal static AIAgentHostOptions CreateAgentHostOptions()
|
||||||
{
|
{
|
||||||
return new AIAgentHostOptions
|
return AgentHostOptionsFactory.CreateDefault();
|
||||||
{
|
|
||||||
EmitAgentUpdateEvents = null,
|
|
||||||
EmitAgentResponseEvents = false,
|
|
||||||
InterceptUserInputRequests = false,
|
|
||||||
InterceptUnterminatedFunctionCalls = false,
|
|
||||||
ReassignOtherAgentsAsUsers = true,
|
|
||||||
ForwardIncomingMessages = true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static HandoffWorkflowBuilder CreateHandoffWorkflowBuilder(
|
internal static HandoffWorkflowBuilder CreateHandoffWorkflowBuilder(
|
||||||
AIAgent entryAgent,
|
AIAgent entryAgent,
|
||||||
HandoffModeSettingsDto? settings = null)
|
HandoffModeSettingsDto? settings = null)
|
||||||
{
|
{
|
||||||
HandoffModeSettingsDto effectiveSettings = settings ?? new HandoffModeSettingsDto();
|
return WorkflowOrchestrationFactory.CreateHandoffWorkflowBuilder(entryAgent, settings);
|
||||||
HandoffWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
|
||||||
.WithToolCallFilteringBehavior(MapHandoffToolCallFiltering(effectiveSettings.ToolCallFiltering))
|
|
||||||
.WithHandoffInstructions(NormalizeOptionalString(effectiveSettings.HandoffInstructions)
|
|
||||||
?? HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
|
||||||
|
|
||||||
if (effectiveSettings.ReturnToPrevious)
|
|
||||||
{
|
|
||||||
builder = builder.EnableReturnToPrevious();
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static Workflow CreateHandoffWorkflow(
|
internal static Workflow CreateHandoffWorkflow(
|
||||||
WorkflowDefinitionDto workflowDefinition,
|
WorkflowDefinitionDto workflowDefinition,
|
||||||
IReadOnlyList<AIAgent> agents)
|
IReadOnlyList<AIAgent> agents)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(workflowDefinition);
|
return WorkflowOrchestrationFactory.CreateHandoffWorkflow(workflowDefinition, agents);
|
||||||
ArgumentNullException.ThrowIfNull(agents);
|
|
||||||
|
|
||||||
IReadOnlyList<WorkflowNodeDto> agentNodes = workflowDefinition.GetAgentNodes();
|
|
||||||
Dictionary<string, AIAgent> agentsById = CreateAgentMap(agents);
|
|
||||||
WorkflowNodeDto triageNode = ResolveTriageAgentNode(workflowDefinition, agentNodes);
|
|
||||||
AIAgent triageAgent = ResolveAgentForNode(triageNode, agentsById);
|
|
||||||
HandoffModeSettingsDto? settings = workflowDefinition.Settings.ModeSettings?.Handoff;
|
|
||||||
HandoffWorkflowBuilder builder = CreateHandoffWorkflowBuilder(triageAgent, settings);
|
|
||||||
|
|
||||||
List<WorkflowNodeDto> specialistNodes = agentNodes
|
|
||||||
.Where(node => !string.Equals(node.Id, triageNode.Id, StringComparison.Ordinal))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (specialistNodes.Count == 0)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Handoff workflows require at least one specialist agent in addition to the triage agent.");
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (WorkflowNodeDto specialistNode in specialistNodes)
|
|
||||||
{
|
|
||||||
AIAgent specialistAgent = ResolveAgentForNode(specialistNode, agentsById);
|
|
||||||
builder.WithHandoff(
|
|
||||||
triageAgent,
|
|
||||||
specialistAgent,
|
|
||||||
HandoffWorkflowGuidance.CreateForwardReason(specialistNode));
|
|
||||||
|
|
||||||
if (settings?.ReturnToPrevious != true)
|
|
||||||
{
|
|
||||||
builder.WithHandoff(
|
|
||||||
specialistAgent,
|
|
||||||
triageAgent,
|
|
||||||
HandoffWorkflowGuidance.CreateReturnReason(triageNode));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.Build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static GroupChatWorkflowBuilder CreateGroupChatWorkflowBuilder(
|
internal static GroupChatWorkflowBuilder CreateGroupChatWorkflowBuilder(
|
||||||
WorkflowDefinitionDto workflowDefinition,
|
WorkflowDefinitionDto workflowDefinition,
|
||||||
IReadOnlyList<AIAgent> agents)
|
IReadOnlyList<AIAgent> agents)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(workflowDefinition);
|
return WorkflowOrchestrationFactory.CreateGroupChatWorkflowBuilder(workflowDefinition, agents);
|
||||||
ArgumentNullException.ThrowIfNull(agents);
|
|
||||||
|
|
||||||
int maxRounds = ResolveGroupChatMaxRounds(workflowDefinition);
|
|
||||||
GroupChatWorkflowBuilder builder = AgentWorkflowBuilder.CreateGroupChatBuilderWith(
|
|
||||||
participants => new RoundRobinGroupChatManager(participants)
|
|
||||||
{
|
|
||||||
MaximumIterationCount = maxRounds,
|
|
||||||
})
|
|
||||||
.AddParticipants(agents);
|
|
||||||
|
|
||||||
string? name = NormalizeOptionalString(workflowDefinition.Name);
|
|
||||||
if (name is not null)
|
|
||||||
{
|
|
||||||
builder.WithName(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
string? description = NormalizeOptionalString(workflowDefinition.Description);
|
|
||||||
if (description is not null)
|
|
||||||
{
|
|
||||||
builder.WithDescription(description);
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static Workflow CreateGroupChatWorkflow(
|
internal static Workflow CreateGroupChatWorkflow(
|
||||||
WorkflowDefinitionDto workflowDefinition,
|
WorkflowDefinitionDto workflowDefinition,
|
||||||
IReadOnlyList<AIAgent> agents)
|
IReadOnlyList<AIAgent> agents)
|
||||||
{
|
{
|
||||||
return CreateGroupChatWorkflowBuilder(workflowDefinition, agents).Build();
|
return WorkflowOrchestrationFactory.CreateGroupChatWorkflow(workflowDefinition, agents);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public override async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
foreach (IAsyncDisposable disposable in _disposables)
|
foreach (IAsyncDisposable disposable in _disposables)
|
||||||
{
|
{
|
||||||
@@ -380,90 +304,6 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<string, AIAgent> CreateAgentMap(IReadOnlyList<AIAgent> agents)
|
|
||||||
{
|
|
||||||
Dictionary<string, AIAgent> agentMap = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (AIAgent agent in agents)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(agent.Id))
|
|
||||||
{
|
|
||||||
agentMap[agent.Id] = agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(agent.Name))
|
|
||||||
{
|
|
||||||
agentMap[agent.Name] = agent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return agentMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AIAgent ResolveAgentForNode(
|
|
||||||
WorkflowNodeDto node,
|
|
||||||
IReadOnlyDictionary<string, AIAgent> agentsById)
|
|
||||||
{
|
|
||||||
string agentId = node.GetAgentId();
|
|
||||||
if (agentsById.TryGetValue(agentId, out AIAgent? agent))
|
|
||||||
{
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
string agentName = node.GetAgentName();
|
|
||||||
if (agentsById.TryGetValue(agentName, out agent))
|
|
||||||
{
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new InvalidOperationException($"Workflow agent \"{agentId}\" could not be resolved from the constructed Copilot agents.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static WorkflowNodeDto ResolveTriageAgentNode(
|
|
||||||
WorkflowDefinitionDto workflowDefinition,
|
|
||||||
IReadOnlyList<WorkflowNodeDto> agentNodes)
|
|
||||||
{
|
|
||||||
if (agentNodes.Count == 0)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Handoff workflows require at least one agent node.");
|
|
||||||
}
|
|
||||||
|
|
||||||
string? triageAgentNodeId = NormalizeOptionalString(workflowDefinition.Settings.ModeSettings?.Handoff?.TriageAgentNodeId);
|
|
||||||
if (triageAgentNodeId is null)
|
|
||||||
{
|
|
||||||
return agentNodes[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkflowNodeDto? triageNode = agentNodes.FirstOrDefault(node => string.Equals(node.Id, triageAgentNodeId, StringComparison.Ordinal));
|
|
||||||
return triageNode ?? throw new InvalidOperationException(
|
|
||||||
$"Handoff workflow triage agent node \"{triageAgentNodeId}\" was not found in the workflow graph.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HandoffToolCallFilteringBehavior MapHandoffToolCallFiltering(string? value)
|
|
||||||
{
|
|
||||||
return value?.Trim().ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"none" => HandoffToolCallFilteringBehavior.None,
|
|
||||||
"all" => HandoffToolCallFilteringBehavior.All,
|
|
||||||
_ => HandoffToolCallFilteringBehavior.HandoffOnly,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int ResolveGroupChatMaxRounds(WorkflowDefinitionDto workflowDefinition)
|
|
||||||
{
|
|
||||||
int? configuredMaxRounds = workflowDefinition.Settings.ModeSettings?.GroupChat?.MaxRounds;
|
|
||||||
if (configuredMaxRounds is > 0)
|
|
||||||
{
|
|
||||||
return configuredMaxRounds.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workflowDefinition.Settings.MaxIterations is > 0)
|
|
||||||
{
|
|
||||||
return workflowDefinition.Settings.MaxIterations.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? ResolveEffectiveAgent(
|
private static string? ResolveEffectiveAgent(
|
||||||
string? defaultAgent,
|
string? defaultAgent,
|
||||||
RunTurnPromptInvocationDto? promptInvocation)
|
RunTurnPromptInvocationDto? promptInvocation)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ internal sealed class CopilotAgentProvider : IAgentProvider
|
|||||||
public ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator)
|
public ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(workflowValidator);
|
ArgumentNullException.ThrowIfNull(workflowValidator);
|
||||||
return new CopilotWorkflowRunner(workflowValidator);
|
return new AgentWorkflowTurnRunner(new CopilotTurnRunnerSupport(), workflowValidator);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<SidecarCapabilitiesDto> GetCapabilitiesAsync(CancellationToken cancellationToken)
|
public Task<SidecarCapabilitiesDto> GetCapabilitiesAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Aryx.AgentHost.Contracts;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Aryx.AgentHost.Services;
|
||||||
|
|
||||||
|
internal sealed class CopilotTranscriptProjector : IProviderTranscriptProjector
|
||||||
|
{
|
||||||
|
public static CopilotTranscriptProjector Instance { get; } = new();
|
||||||
|
|
||||||
|
private CopilotTranscriptProjector()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatMessage ToChatMessage(ChatMessageDto message)
|
||||||
|
=> WorkflowTranscriptProjector.ToChatMessage(message);
|
||||||
|
|
||||||
|
public void AttachMessageMode(IList<ChatMessage> messages, string? messageMode)
|
||||||
|
=> WorkflowTranscriptProjector.AttachMessageMode(messages, messageMode);
|
||||||
|
|
||||||
|
public List<ChatMessage> SelectNewOutputMessages(
|
||||||
|
IReadOnlyList<ChatMessage> outputMessages,
|
||||||
|
IReadOnlyList<ChatMessage> inputMessages)
|
||||||
|
=> WorkflowTranscriptProjector.SelectNewOutputMessages(outputMessages, inputMessages);
|
||||||
|
|
||||||
|
public List<ChatMessageDto> ProjectCompletedMessagesFromSegments(
|
||||||
|
RunTurnCommandDto command,
|
||||||
|
IReadOnlyList<ChatMessage> newMessages,
|
||||||
|
IReadOnlyList<TranscriptSegment> segments,
|
||||||
|
AgentIdentity? fallbackAgent = null)
|
||||||
|
=> WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
|
||||||
|
command,
|
||||||
|
newMessages,
|
||||||
|
segments,
|
||||||
|
fallbackAgent);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using Aryx.AgentHost.Contracts;
|
||||||
|
using GitHub.Copilot.SDK;
|
||||||
|
|
||||||
|
namespace Aryx.AgentHost.Services;
|
||||||
|
|
||||||
|
internal sealed class CopilotTurnRunnerSupport : IProviderTurnSupport
|
||||||
|
{
|
||||||
|
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
|
||||||
|
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
|
||||||
|
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
|
||||||
|
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
|
||||||
|
private readonly IProviderEventAdapter _providerEventAdapter = new CopilotEventAdapter();
|
||||||
|
|
||||||
|
public async Task<ProviderAgentBundle> CreateAgentBundleAsync(
|
||||||
|
RunTurnCommandDto command,
|
||||||
|
TurnExecutionState state,
|
||||||
|
Func<SidecarEventDto, Task> onEvent,
|
||||||
|
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||||
|
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||||
|
CancellationTokenSource runCancellation,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await CopilotAgentBundle.CreateAsync(
|
||||||
|
command,
|
||||||
|
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
|
||||||
|
command,
|
||||||
|
agent,
|
||||||
|
request,
|
||||||
|
invocation,
|
||||||
|
state.ToolNamesByCallId,
|
||||||
|
activity => AgentWorkflowTurnRunner.EmitActivityAsync(command, state, activity, onEvent),
|
||||||
|
onApproval,
|
||||||
|
runCancellation.Token),
|
||||||
|
(agent, request, invocation) => _userInputCoordinator.RequestUserInputAsync(
|
||||||
|
command,
|
||||||
|
agent,
|
||||||
|
request,
|
||||||
|
invocation,
|
||||||
|
onUserInput,
|
||||||
|
runCancellation.Token),
|
||||||
|
(agent, sessionEvent) => ObserveSessionEvent(command, state, runCancellation, agent, sessionEvent),
|
||||||
|
cancellationToken)
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<UserInputResponse> RequestRequestPortUserInputAsync(
|
||||||
|
RunTurnCommandDto command,
|
||||||
|
UserInputRequest request,
|
||||||
|
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _userInputCoordinator.RequestUserInputAsync(
|
||||||
|
command,
|
||||||
|
request,
|
||||||
|
onUserInput,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExitPlanModeRequestedEventDto? ConsumePendingExitPlanModeRequest(string requestId)
|
||||||
|
{
|
||||||
|
return _exitPlanModeCoordinator.ConsumePendingRequest(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearRequestState(string requestId)
|
||||||
|
{
|
||||||
|
_approvalCoordinator.ClearRequestApprovals(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ObserveSessionEvent(
|
||||||
|
RunTurnCommandDto command,
|
||||||
|
TurnExecutionState state,
|
||||||
|
CancellationTokenSource runCancellation,
|
||||||
|
WorkflowNodeDto agent,
|
||||||
|
SessionEvent sessionEvent)
|
||||||
|
{
|
||||||
|
if (_providerEventAdapter.TryAdapt(sessionEvent) is { } providerEvent)
|
||||||
|
{
|
||||||
|
state.ObserveSessionEvent(agent, providerEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionEvent is McpOauthRequiredEvent mcpOauthRequired)
|
||||||
|
{
|
||||||
|
state.EnqueuePendingMcpOauthRequest(
|
||||||
|
_mcpOAuthCoordinator.BuildMcpOauthRequiredEvent(command, agent, mcpOauthRequired));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionEvent is ExitPlanModeRequestedEvent exitPlanModeRequested)
|
||||||
|
{
|
||||||
|
_exitPlanModeCoordinator.RecordExitPlanModeRequest(command, agent, exitPlanModeRequested);
|
||||||
|
runCancellation.Cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Aryx.AgentHost.Contracts;
|
using Aryx.AgentHost.Contracts;
|
||||||
using GitHub.Copilot.SDK;
|
using GitHub.Copilot.SDK;
|
||||||
@@ -12,301 +10,63 @@ using Microsoft.Extensions.AI;
|
|||||||
|
|
||||||
namespace Aryx.AgentHost.Services;
|
namespace Aryx.AgentHost.Services;
|
||||||
|
|
||||||
public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
public sealed class CopilotWorkflowRunner : AgentWorkflowTurnRunner
|
||||||
{
|
{
|
||||||
private const string HandoffFunctionPrefix = "handoff_to_";
|
|
||||||
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(WorkflowValidator? workflowValidator = null)
|
public CopilotWorkflowRunner(WorkflowValidator? workflowValidator = null)
|
||||||
|
: base(new CopilotTurnRunnerSupport(), workflowValidator)
|
||||||
{
|
{
|
||||||
_workflowValidator = workflowValidator ?? new WorkflowValidator();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
internal new static Workflow BuildWorkflowForCommand(
|
||||||
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 = _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary)
|
|
||||||
.FirstOrDefault()?.Message;
|
|
||||||
if (validationError is not null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(validationError);
|
|
||||||
}
|
|
||||||
|
|
||||||
IProviderEventAdapter providerEventAdapter = new CopilotEventAdapter();
|
|
||||||
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) =>
|
|
||||||
{
|
|
||||||
if (providerEventAdapter.TryAdapt(sessionEvent) is { } providerEvent)
|
|
||||||
{
|
|
||||||
state.ObserveSessionEvent(agent, providerEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = BuildWorkflowForCommand(command, bundle.Agents, _workflowRunner);
|
|
||||||
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))
|
|
||||||
{
|
|
||||||
if (evt is RequestInfoEvent requestInfo
|
|
||||||
&& await TryHandleRequestPortRequestAsync(
|
|
||||||
command,
|
|
||||||
requestInfo,
|
|
||||||
run,
|
|
||||||
onUserInput,
|
|
||||||
runCancellation.Token).ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 Workflow BuildWorkflowForCommand(
|
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
IReadOnlyList<AIAgent> agents,
|
IReadOnlyList<AIAgent> agents,
|
||||||
WorkflowRunner? workflowRunner = null)
|
WorkflowRunner? workflowRunner = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
return AgentWorkflowTurnRunner.BuildWorkflowForCommand(command, agents, workflowRunner);
|
||||||
ArgumentNullException.ThrowIfNull(agents);
|
|
||||||
|
|
||||||
return NormalizeOrchestrationMode(command.Workflow.Settings.OrchestrationMode) switch
|
|
||||||
{
|
|
||||||
"handoff" => CopilotAgentBundle.CreateHandoffWorkflow(command.Workflow, agents),
|
|
||||||
"group-chat" => CopilotAgentBundle.CreateGroupChatWorkflow(command.Workflow, agents),
|
|
||||||
_ => (workflowRunner ?? new WorkflowRunner()).BuildWorkflow(command.Workflow, agents, command.WorkflowLibrary),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static FileSystemJsonCheckpointStore? CreateCheckpointStore(RunTurnCommandDto command)
|
internal new static FileSystemJsonCheckpointStore? CreateCheckpointStore(RunTurnCommandDto command)
|
||||||
{
|
{
|
||||||
if (!ShouldEnableWorkflowCheckpointing(command))
|
return AgentWorkflowTurnRunner.CreateCheckpointStore(command);
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
DirectoryInfo checkpointDirectory = new(GetCheckpointStorePath(command));
|
|
||||||
return new FileSystemJsonCheckpointStore(checkpointDirectory);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command)
|
internal new static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
return AgentWorkflowTurnRunner.ShouldEnableWorkflowCheckpointing(command);
|
||||||
return command.Workflow.Settings.Checkpointing.Enabled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static string GetCheckpointStorePath(RunTurnCommandDto command)
|
internal new static string GetCheckpointStorePath(RunTurnCommandDto command)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
return AgentWorkflowTurnRunner.GetCheckpointStorePath(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 string? NormalizeOrchestrationMode(string? value)
|
internal new static InProcessExecutionEnvironment CreateExecutionEnvironment(
|
||||||
{
|
|
||||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ValueTask<StreamingRun> OpenWorkflowRunAsync(
|
|
||||||
RunTurnCommandDto command,
|
|
||||||
Workflow workflow,
|
|
||||||
IReadOnlyList<ChatMessage> inputMessages,
|
|
||||||
CheckpointManager? checkpointManager)
|
|
||||||
{
|
|
||||||
InProcessExecutionEnvironment environment = CreateExecutionEnvironment(command, checkpointManager);
|
|
||||||
if (checkpointManager is not null && command.ResumeFromCheckpoint is { } resumeFromCheckpoint)
|
|
||||||
{
|
|
||||||
return environment.ResumeStreamingAsync(
|
|
||||||
workflow,
|
|
||||||
new CheckpointInfo(resumeFromCheckpoint.WorkflowSessionId, resumeFromCheckpoint.CheckpointId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return environment.RunStreamingAsync(workflow, inputMessages.ToList(), command.RequestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static InProcessExecutionEnvironment CreateExecutionEnvironment(
|
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
CheckpointManager? checkpointManager)
|
CheckpointManager? checkpointManager)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
return AgentWorkflowTurnRunner.CreateExecutionEnvironment(command, checkpointManager);
|
||||||
|
|
||||||
string executionMode = command.Workflow.Settings.ExecutionMode?.Trim() ?? "off-thread";
|
|
||||||
InProcessExecutionEnvironment environment = string.Equals(
|
|
||||||
executionMode,
|
|
||||||
"lockstep",
|
|
||||||
StringComparison.OrdinalIgnoreCase)
|
|
||||||
? InProcessExecution.Lockstep
|
|
||||||
: InProcessExecution.OffThread;
|
|
||||||
|
|
||||||
return checkpointManager is null ? environment : environment.WithCheckpointing(checkpointManager);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void ConfigureHookLifecycleEventSuppression(
|
internal static void ConfigureHookLifecycleEventSuppression(
|
||||||
CopilotTurnExecutionState state,
|
CopilotTurnExecutionState state,
|
||||||
CopilotAgentBundle bundle)
|
CopilotAgentBundle bundle)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(state);
|
AgentWorkflowTurnRunner.ConfigureHookLifecycleEventSuppression(state, bundle);
|
||||||
ArgumentNullException.ThrowIfNull(bundle);
|
|
||||||
|
|
||||||
state.SuppressHookLifecycleEvents = !bundle.HasConfiguredHooks;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task EmitPendingEventsAsync(
|
internal new static UserInputRequest CreateRequestPortUserInputRequest(
|
||||||
CopilotTurnExecutionState state,
|
AgentWorkflowTurnRunner.WorkflowRequestPortMetadata metadata,
|
||||||
Func<SidecarEventDto, Task> onEvent)
|
RequestInfoEvent requestInfo)
|
||||||
{
|
{
|
||||||
foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents())
|
return AgentWorkflowTurnRunner.CreateRequestPortUserInputRequest(metadata, requestInfo);
|
||||||
{
|
|
||||||
await onEvent(pendingEvent).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task EmitPendingMcpOauthRequestsAsync(
|
internal new static object CoerceRequestPortResponse(string responseType, string? answer)
|
||||||
CopilotTurnExecutionState state,
|
|
||||||
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired)
|
|
||||||
{
|
{
|
||||||
foreach (McpOauthRequiredEventDto request in state.DrainPendingMcpOauthRequests())
|
return AgentWorkflowTurnRunner.CoerceRequestPortResponse(responseType, answer);
|
||||||
{
|
|
||||||
await onMcpOAuthRequired(request).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task ResolveApprovalAsync(
|
private static Task<bool> HandleWorkflowEventAsync(
|
||||||
ResolveApprovalCommandDto command,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task ResolveUserInputAsync(
|
|
||||||
ResolveUserInputCommandDto command,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return _userInputCoordinator.ResolveUserInputAsync(command, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> TryHandleRequestPortRequestAsync(
|
|
||||||
RunTurnCommandDto command,
|
|
||||||
RequestInfoEvent requestInfo,
|
|
||||||
StreamingRun run,
|
|
||||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!TryResolveRequestPortMetadata(command.Workflow, requestInfo, out WorkflowRequestPortMetadata? metadata))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
UserInputRequest userInputRequest = CreateRequestPortUserInputRequest(metadata!, requestInfo);
|
|
||||||
UserInputResponse response = await _userInputCoordinator.RequestUserInputAsync(
|
|
||||||
command,
|
|
||||||
userInputRequest,
|
|
||||||
onUserInput,
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
object coercedResponse = CoerceRequestPortResponse(metadata!.ResponseType, response.Answer);
|
|
||||||
await run.SendResponseAsync(requestInfo.Request.CreateResponse(coercedResponse)).ConfigureAwait(false);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> HandleWorkflowEventAsync(
|
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
WorkflowEvent evt,
|
WorkflowEvent evt,
|
||||||
IReadOnlyList<ChatMessage> inputMessages,
|
IReadOnlyList<ChatMessage> inputMessages,
|
||||||
@@ -314,469 +74,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
Func<TurnDeltaEventDto, Task> onDelta,
|
Func<TurnDeltaEventDto, Task> onDelta,
|
||||||
Func<SidecarEventDto, Task> onEvent)
|
Func<SidecarEventDto, Task> onEvent)
|
||||||
{
|
{
|
||||||
if (evt is ExecutorInvokedEvent invoked)
|
return AgentWorkflowTurnRunner.HandleWorkflowEventAsync(
|
||||||
{
|
|
||||||
if (AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
|
||||||
command.Workflow,
|
|
||||||
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.Workflow,
|
|
||||||
completed.ExecutorId,
|
|
||||||
state.ActiveAgent,
|
|
||||||
out AgentIdentity completedAgent))
|
|
||||||
{
|
|
||||||
TraceHandoff(command, $"Executor completed: {completed.ExecutorId} -> {completedAgent.AgentName} ({completedAgent.AgentId}).");
|
|
||||||
state.QueueCompletedActivity(completedAgent);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static UserInputRequest CreateRequestPortUserInputRequest(
|
|
||||||
WorkflowRequestPortMetadata metadata,
|
|
||||||
RequestInfoEvent requestInfo)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(metadata);
|
|
||||||
ArgumentNullException.ThrowIfNull(requestInfo);
|
|
||||||
|
|
||||||
string question = metadata.Prompt
|
|
||||||
?? BuildRequestPortFallbackQuestion(metadata, requestInfo);
|
|
||||||
|
|
||||||
bool expectsBoolean = IsBooleanResponseType(metadata.ResponseType);
|
|
||||||
return new UserInputRequest
|
|
||||||
{
|
|
||||||
Question = question,
|
|
||||||
Choices = expectsBoolean ? ["true", "false"] : null,
|
|
||||||
AllowFreeform = true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static object CoerceRequestPortResponse(string responseType, string? answer)
|
|
||||||
{
|
|
||||||
string normalizedResponseType = responseType.Trim();
|
|
||||||
string trimmedAnswer = answer?.Trim() ?? string.Empty;
|
|
||||||
|
|
||||||
if (IsStringResponseType(normalizedResponseType))
|
|
||||||
{
|
|
||||||
return trimmedAnswer;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsBooleanResponseType(normalizedResponseType))
|
|
||||||
{
|
|
||||||
return trimmedAnswer.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"true" or "t" or "yes" or "y" or "1" => true,
|
|
||||||
"false" or "f" or "no" or "n" or "0" => false,
|
|
||||||
_ => throw new InvalidOperationException(
|
|
||||||
$"Request port response type \"{responseType}\" requires a boolean answer."),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsNumericResponseType(normalizedResponseType))
|
|
||||||
{
|
|
||||||
if (double.TryParse(trimmedAnswer, NumberStyles.Float, CultureInfo.InvariantCulture, out double numeric))
|
|
||||||
{
|
|
||||||
return numeric;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Request port response type \"{responseType}\" requires a numeric answer.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsJsonResponseType(normalizedResponseType))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return JsonDocument.Parse(trimmedAnswer).RootElement.Clone();
|
|
||||||
}
|
|
||||||
catch (JsonException ex)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Request port response type \"{responseType}\" requires a valid JSON answer.",
|
|
||||||
ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmedAnswer;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.Workflow,
|
|
||||||
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,
|
command,
|
||||||
$"Activity emitted: {activity.ActivityType} -> {activity.AgentName ?? activity.AgentId ?? "<unknown>"}.");
|
evt,
|
||||||
await onEvent(activity).ConfigureAwait(false);
|
inputMessages,
|
||||||
|
state,
|
||||||
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
CopilotTranscriptProjector.Instance,
|
||||||
&& !string.IsNullOrWhiteSpace(activity.AgentId)
|
onDelta,
|
||||||
&& !string.IsNullOrWhiteSpace(activity.AgentName))
|
onEvent);
|
||||||
{
|
|
||||||
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.Workflow,
|
|
||||||
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 bool TryResolveRequestPortMetadata(
|
|
||||||
WorkflowDefinitionDto? workflow,
|
|
||||||
RequestInfoEvent requestInfo,
|
|
||||||
out WorkflowRequestPortMetadata? metadata)
|
|
||||||
{
|
|
||||||
metadata = null;
|
|
||||||
if (workflow is null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
string portId = requestInfo.Request.PortInfo.PortId;
|
|
||||||
WorkflowNodeDto? node = workflow.Graph.Nodes.FirstOrDefault(candidate =>
|
|
||||||
string.Equals(candidate.Kind, "request-port", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& string.Equals(candidate.Config.PortId, portId, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (node is null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata = new WorkflowRequestPortMetadata(
|
|
||||||
node.Id,
|
|
||||||
string.IsNullOrWhiteSpace(node.Label) ? node.Id : node.Label,
|
|
||||||
node.Config.PortId ?? portId,
|
|
||||||
node.Config.RequestType ?? string.Empty,
|
|
||||||
node.Config.ResponseType ?? string.Empty,
|
|
||||||
string.IsNullOrWhiteSpace(node.Config.Prompt) ? null : node.Config.Prompt.Trim());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildRequestPortFallbackQuestion(
|
|
||||||
WorkflowRequestPortMetadata metadata,
|
|
||||||
RequestInfoEvent requestInfo)
|
|
||||||
{
|
|
||||||
if (requestInfo.Request.Data.Is<WorkflowRequestPortPromptRequest>(out WorkflowRequestPortPromptRequest? promptRequest))
|
|
||||||
{
|
|
||||||
string baseQuestion = $"Provide a {metadata.ResponseType} response for \"{promptRequest.NodeLabel}\".";
|
|
||||||
if (!string.IsNullOrWhiteSpace(promptRequest.InputSummary))
|
|
||||||
{
|
|
||||||
return $"{baseQuestion} Current input: {promptRequest.InputSummary}";
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseQuestion;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $"Provide a {metadata.ResponseType} response for request port \"{metadata.NodeLabel}\" ({metadata.PortId}).";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsStringResponseType(string responseType)
|
|
||||||
=> string.Equals(responseType, "string", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(responseType, "text", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private static bool IsBooleanResponseType(string responseType)
|
|
||||||
=> string.Equals(responseType, "bool", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(responseType, "boolean", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private static bool IsNumericResponseType(string responseType)
|
|
||||||
=> string.Equals(responseType, "number", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(responseType, "int", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(responseType, "float", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(responseType, "double", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(responseType, "decimal", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private static bool IsJsonResponseType(string responseType)
|
|
||||||
=> string.Equals(responseType, "json", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(responseType, "object", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(responseType, "array", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
internal sealed record WorkflowRequestPortMetadata(
|
|
||||||
string NodeId,
|
|
||||||
string NodeLabel,
|
|
||||||
string PortId,
|
|
||||||
string RequestType,
|
|
||||||
string ResponseType,
|
|
||||||
string? Prompt);
|
|
||||||
|
|
||||||
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 (!command.Workflow.IsOrchestrationMode("handoff"))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.Error.WriteLine($"[aryx handoff] {message}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,674 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Aryx.AgentHost.Contracts;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Aryx.AgentHost.Services;
|
||||||
|
|
||||||
|
internal class TurnExecutionState
|
||||||
|
{
|
||||||
|
private readonly RunTurnCommandDto _command;
|
||||||
|
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly HashSet<string> _reclassifiedMessageIds = new(StringComparer.Ordinal);
|
||||||
|
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
|
||||||
|
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
|
||||||
|
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
|
||||||
|
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
|
||||||
|
private int _fallbackMessageIndex;
|
||||||
|
private string? _lastObservedMessageId;
|
||||||
|
|
||||||
|
public TurnExecutionState(RunTurnCommandDto command)
|
||||||
|
{
|
||||||
|
_command = command;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConcurrentDictionary<string, string> ToolNamesByCallId { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public AgentIdentity? ActiveAgent { get; private set; }
|
||||||
|
|
||||||
|
public List<ChatMessageDto> CompletedMessages { get; private set; } = [];
|
||||||
|
|
||||||
|
public bool HasPendingExitPlanModeRequest { get; private set; }
|
||||||
|
|
||||||
|
public bool SuppressHookLifecycleEvents { get; set; }
|
||||||
|
|
||||||
|
public async Task EmitThinkingIfNeeded(
|
||||||
|
AgentIdentity agent,
|
||||||
|
Func<SidecarEventDto, Task> onEvent)
|
||||||
|
{
|
||||||
|
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
||||||
|
if (thinkingActivity is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onEvent(thinkingActivity).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void QueueThinkingIfNeeded(AgentIdentity agent)
|
||||||
|
{
|
||||||
|
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
||||||
|
if (thinkingActivity is not null)
|
||||||
|
{
|
||||||
|
_pendingEvents.Enqueue(thinkingActivity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void QueueCompletedActivity(AgentIdentity agent)
|
||||||
|
{
|
||||||
|
_pendingEvents.Enqueue(CreateCompletedActivity(agent));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyEvent(SidecarEventDto evt)
|
||||||
|
{
|
||||||
|
if (evt is AgentActivityEventDto activity
|
||||||
|
&& string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
||||||
|
&& !string.IsNullOrWhiteSpace(activity.AgentId)
|
||||||
|
&& !string.IsNullOrWhiteSpace(activity.AgentName))
|
||||||
|
{
|
||||||
|
ActiveAgent = new AgentIdentity(activity.AgentId, activity.AgentName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, ProviderSessionEvent sessionEvent)
|
||||||
|
{
|
||||||
|
AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity(
|
||||||
|
_command.Workflow,
|
||||||
|
agentDefinition.GetAgentId(),
|
||||||
|
agentDefinition.GetAgentName());
|
||||||
|
|
||||||
|
switch (sessionEvent)
|
||||||
|
{
|
||||||
|
case ProviderAssistantMessageDeltaEvent messageDelta:
|
||||||
|
RecordObservedAgentForMessage(agent, messageDelta.MessageId);
|
||||||
|
QueueThinkingIfNeeded(agent);
|
||||||
|
break;
|
||||||
|
case ProviderAssistantMessageEvent assistantMessage:
|
||||||
|
RecordObservedAgentForMessage(agent, assistantMessage.MessageId);
|
||||||
|
QueueThinkingIfNeeded(agent);
|
||||||
|
if (assistantMessage.HasToolRequests)
|
||||||
|
{
|
||||||
|
QueueMessageReclassifiedIfNeeded(assistantMessage.MessageId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ProviderToolExecutionStartEvent toolExecutionStart:
|
||||||
|
string toolCallId = toolExecutionStart.ToolCallId;
|
||||||
|
string toolName = toolExecutionStart.ToolName;
|
||||||
|
ToolNamesByCallId[toolCallId] = toolName;
|
||||||
|
ActiveAgent = agent;
|
||||||
|
AgentActivityEventDto? toolActivity = CreateToolCallingActivity(
|
||||||
|
agent, toolName, toolCallId, toolExecutionStart.ToolArguments);
|
||||||
|
if (toolActivity is not null)
|
||||||
|
{
|
||||||
|
_pendingEvents.Enqueue(toolActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
|
||||||
|
break;
|
||||||
|
case ProviderAssistantIntentEvent intentEvent:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
QueueThinkingIfNeeded(agent);
|
||||||
|
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Intent);
|
||||||
|
if (assistantIntent is not null)
|
||||||
|
{
|
||||||
|
_pendingEvents.Enqueue(assistantIntent);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ProviderAssistantReasoningDeltaEvent reasoningDelta:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
QueueThinkingIfNeeded(agent);
|
||||||
|
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(
|
||||||
|
agent,
|
||||||
|
reasoningDelta.ReasoningId,
|
||||||
|
reasoningDelta.DeltaContent);
|
||||||
|
if (reasoningDeltaEvent is not null)
|
||||||
|
{
|
||||||
|
_pendingEvents.Enqueue(reasoningDeltaEvent);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ProviderSubagentStartedEvent started:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
_pendingEvents.Enqueue(CreateSubagentStartedEvent(agent, started));
|
||||||
|
break;
|
||||||
|
case ProviderSubagentCompletedEvent completed:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
_pendingEvents.Enqueue(CreateSubagentCompletedEvent(agent, completed));
|
||||||
|
break;
|
||||||
|
case ProviderSubagentFailedEvent failed:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
_pendingEvents.Enqueue(CreateSubagentFailedEvent(agent, failed));
|
||||||
|
break;
|
||||||
|
case ProviderSubagentSelectedEvent selected:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
_pendingEvents.Enqueue(CreateSubagentSelectedEvent(agent, selected));
|
||||||
|
break;
|
||||||
|
case ProviderSubagentDeselectedEvent:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
_pendingEvents.Enqueue(CreateSubagentDeselectedEvent(agent));
|
||||||
|
break;
|
||||||
|
case ProviderSkillInvokedEvent skillInvoked:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
_pendingEvents.Enqueue(CreateSkillInvokedEvent(agent, skillInvoked));
|
||||||
|
break;
|
||||||
|
case ProviderHookStartEvent hookStart:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
if (!SuppressHookLifecycleEvents)
|
||||||
|
{
|
||||||
|
_pendingEvents.Enqueue(CreateHookLifecycleEvent(
|
||||||
|
agent,
|
||||||
|
"start",
|
||||||
|
hookStart.HookInvocationId,
|
||||||
|
hookStart.HookType,
|
||||||
|
input: hookStart.Input));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ProviderHookEndEvent hookEnd:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
if (!SuppressHookLifecycleEvents)
|
||||||
|
{
|
||||||
|
_pendingEvents.Enqueue(CreateHookLifecycleEvent(
|
||||||
|
agent,
|
||||||
|
"end",
|
||||||
|
hookEnd.HookInvocationId,
|
||||||
|
hookEnd.HookType,
|
||||||
|
success: hookEnd.Success,
|
||||||
|
output: hookEnd.Output,
|
||||||
|
error: hookEnd.Error));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ProviderAssistantUsageEvent assistantUsage:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
_pendingEvents.Enqueue(CreateAssistantUsageEvent(agent, assistantUsage));
|
||||||
|
break;
|
||||||
|
case ProviderSessionUsageEvent usageInfo:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
_pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo));
|
||||||
|
break;
|
||||||
|
case ProviderSessionCompactionStartEvent compactionStart:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
_pendingEvents.Enqueue(CreateCompactionStartEvent(agent, compactionStart));
|
||||||
|
break;
|
||||||
|
case ProviderSessionCompactionCompleteEvent compactionComplete:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
_pendingEvents.Enqueue(CreateCompactionCompleteEvent(agent, compactionComplete));
|
||||||
|
break;
|
||||||
|
case ProviderPendingMessagesModifiedEvent:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
_pendingEvents.Enqueue(CreatePendingMessagesModifiedEvent(agent));
|
||||||
|
break;
|
||||||
|
case ProviderMcpOauthRequiredEvent:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
break;
|
||||||
|
case ProviderExitPlanModeRequestedEvent:
|
||||||
|
HasPendingExitPlanModeRequest = true;
|
||||||
|
ActiveAgent = agent;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<SidecarEventDto> DrainPendingEvents()
|
||||||
|
{
|
||||||
|
List<SidecarEventDto> pending = [];
|
||||||
|
while (_pendingEvents.TryDequeue(out SidecarEventDto? pendingEvent))
|
||||||
|
{
|
||||||
|
pending.Add(pendingEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnqueuePendingMcpOauthRequest(McpOauthRequiredEventDto request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
_pendingMcpOauthRequests.Enqueue(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<McpOauthRequiredEventDto> DrainPendingMcpOauthRequests()
|
||||||
|
{
|
||||||
|
List<McpOauthRequiredEventDto> pending = [];
|
||||||
|
while (_pendingMcpOauthRequests.TryDequeue(out McpOauthRequiredEventDto? request))
|
||||||
|
{
|
||||||
|
pending.Add(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
|
||||||
|
{
|
||||||
|
agent = default;
|
||||||
|
return !string.IsNullOrWhiteSpace(messageId)
|
||||||
|
&& _observedAgentsByMessageId.TryGetValue(messageId, out agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CreateMessageId(string? messageId)
|
||||||
|
{
|
||||||
|
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public TranscriptSegment AppendDelta(
|
||||||
|
string messageId,
|
||||||
|
string authorName,
|
||||||
|
string delta)
|
||||||
|
{
|
||||||
|
return _transcriptBuffer.AppendDelta(messageId, authorName, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearActiveAgentIfMatching(AgentIdentity completedAgent)
|
||||||
|
{
|
||||||
|
if (ActiveAgent.HasValue
|
||||||
|
&& string.Equals(ActiveAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
ActiveAgent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecordObservedAgentForMessage(AgentIdentity agent, string messageId)
|
||||||
|
{
|
||||||
|
ActiveAgent = agent;
|
||||||
|
_observedAgentsByMessageId[messageId] = agent;
|
||||||
|
_lastObservedMessageId = messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QueueMessageReclassifiedIfNeeded(string? messageId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(messageId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string normalizedMessageId = messageId.Trim();
|
||||||
|
if (!_reclassifiedMessageIds.Add(normalizedMessageId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingEvents.Enqueue(CreateMessageReclassifiedEvent(normalizedMessageId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentActivityEventDto? CreateThinkingActivityIfNeeded(AgentIdentity agent)
|
||||||
|
{
|
||||||
|
ActiveAgent = agent;
|
||||||
|
|
||||||
|
if (!_startedAgents.Add(agent.AgentId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AgentActivityEventDto
|
||||||
|
{
|
||||||
|
Type = "agent-activity",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
ActivityType = "thinking",
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentActivityEventDto? CreateToolCallingActivity(
|
||||||
|
AgentIdentity agent,
|
||||||
|
string toolName,
|
||||||
|
string toolCallId,
|
||||||
|
IReadOnlyDictionary<string, object?>? toolArguments = null)
|
||||||
|
{
|
||||||
|
if (toolName.StartsWith("handoff_to_", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AgentActivityEventDto
|
||||||
|
{
|
||||||
|
Type = "agent-activity",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
ActivityType = "tool-calling",
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
ToolName = toolName,
|
||||||
|
ToolCallId = toolCallId,
|
||||||
|
ToolArguments = toolArguments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentActivityEventDto CreateCompletedActivity(AgentIdentity agent)
|
||||||
|
{
|
||||||
|
return new AgentActivityEventDto
|
||||||
|
{
|
||||||
|
Type = "agent-activity",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
ActivityType = "completed",
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId)
|
||||||
|
{
|
||||||
|
return new MessageReclassifiedEventDto
|
||||||
|
{
|
||||||
|
Type = "message-reclassified",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
MessageId = messageId,
|
||||||
|
NewKind = "thinking",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateCompletedMessages(
|
||||||
|
IReadOnlyList<ChatMessage> allMessages,
|
||||||
|
IReadOnlyList<ChatMessage> inputMessages,
|
||||||
|
IProviderTranscriptProjector transcriptProjector)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(transcriptProjector);
|
||||||
|
|
||||||
|
List<ChatMessage> newMessages = transcriptProjector.SelectNewOutputMessages(allMessages, inputMessages);
|
||||||
|
CompletedMessages = transcriptProjector.ProjectCompletedMessagesFromSegments(
|
||||||
|
_command,
|
||||||
|
newMessages,
|
||||||
|
_transcriptBuffer.Snapshot(),
|
||||||
|
ActiveAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ChatMessageDto> FinalizeCompletedMessages(IProviderTranscriptProjector transcriptProjector)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(transcriptProjector);
|
||||||
|
|
||||||
|
if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0)
|
||||||
|
{
|
||||||
|
CompletedMessages = transcriptProjector.ProjectCompletedMessagesFromSegments(
|
||||||
|
_command,
|
||||||
|
[],
|
||||||
|
_transcriptBuffer.Snapshot(),
|
||||||
|
ActiveAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (ChatMessageDto message in CompletedMessages)
|
||||||
|
{
|
||||||
|
if (_reclassifiedMessageIds.Contains(message.Id))
|
||||||
|
{
|
||||||
|
message.MessageKind = "thinking";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CompletedMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubagentEventDto CreateSubagentStartedEvent(
|
||||||
|
AgentIdentity agent,
|
||||||
|
ProviderSubagentStartedEvent data)
|
||||||
|
{
|
||||||
|
return new SubagentEventDto
|
||||||
|
{
|
||||||
|
Type = "subagent-event",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
EventKind = "started",
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
ToolCallId = data.ToolCallId,
|
||||||
|
CustomAgentName = data.AgentName,
|
||||||
|
CustomAgentDisplayName = data.AgentDisplayName,
|
||||||
|
CustomAgentDescription = data.AgentDescription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubagentEventDto CreateSubagentCompletedEvent(
|
||||||
|
AgentIdentity agent,
|
||||||
|
ProviderSubagentCompletedEvent data)
|
||||||
|
{
|
||||||
|
return new SubagentEventDto
|
||||||
|
{
|
||||||
|
Type = "subagent-event",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
EventKind = "completed",
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
ToolCallId = data.ToolCallId,
|
||||||
|
CustomAgentName = data.AgentName,
|
||||||
|
CustomAgentDisplayName = data.AgentDisplayName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubagentEventDto CreateSubagentFailedEvent(
|
||||||
|
AgentIdentity agent,
|
||||||
|
ProviderSubagentFailedEvent data)
|
||||||
|
{
|
||||||
|
return new SubagentEventDto
|
||||||
|
{
|
||||||
|
Type = "subagent-event",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
EventKind = "failed",
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
ToolCallId = data.ToolCallId,
|
||||||
|
CustomAgentName = data.AgentName,
|
||||||
|
CustomAgentDisplayName = data.AgentDisplayName,
|
||||||
|
Error = data.Error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubagentEventDto CreateSubagentSelectedEvent(
|
||||||
|
AgentIdentity agent,
|
||||||
|
ProviderSubagentSelectedEvent data)
|
||||||
|
{
|
||||||
|
return new SubagentEventDto
|
||||||
|
{
|
||||||
|
Type = "subagent-event",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
EventKind = "selected",
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
CustomAgentName = data.AgentName,
|
||||||
|
CustomAgentDisplayName = data.AgentDisplayName,
|
||||||
|
Tools = data.Tools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubagentEventDto CreateSubagentDeselectedEvent(AgentIdentity agent)
|
||||||
|
{
|
||||||
|
return new SubagentEventDto
|
||||||
|
{
|
||||||
|
Type = "subagent-event",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
EventKind = "deselected",
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private AssistantIntentEventDto? CreateAssistantIntentEvent(
|
||||||
|
AgentIdentity agent,
|
||||||
|
string? intent)
|
||||||
|
{
|
||||||
|
string? normalizedIntent = intent?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedIntent))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AssistantIntentEventDto
|
||||||
|
{
|
||||||
|
Type = "assistant-intent",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
Intent = normalizedIntent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReasoningDeltaEventDto? CreateReasoningDeltaEvent(
|
||||||
|
AgentIdentity agent,
|
||||||
|
string? reasoningId,
|
||||||
|
string? deltaContent)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(reasoningId)
|
||||||
|
|| string.IsNullOrEmpty(deltaContent))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ReasoningDeltaEventDto
|
||||||
|
{
|
||||||
|
Type = "reasoning-delta",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
ReasoningId = reasoningId,
|
||||||
|
ContentDelta = deltaContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private SkillInvokedEventDto CreateSkillInvokedEvent(
|
||||||
|
AgentIdentity agent,
|
||||||
|
ProviderSkillInvokedEvent data)
|
||||||
|
{
|
||||||
|
return new SkillInvokedEventDto
|
||||||
|
{
|
||||||
|
Type = "skill-invoked",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
SkillName = data.SkillName,
|
||||||
|
Path = data.Path,
|
||||||
|
Content = data.Content,
|
||||||
|
AllowedTools = data.AllowedTools,
|
||||||
|
PluginName = data.PluginName,
|
||||||
|
PluginVersion = data.PluginVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private HookLifecycleEventDto CreateHookLifecycleEvent(
|
||||||
|
AgentIdentity agent,
|
||||||
|
string phase,
|
||||||
|
string hookInvocationId,
|
||||||
|
string hookType,
|
||||||
|
object? input = null,
|
||||||
|
bool? success = null,
|
||||||
|
object? output = null,
|
||||||
|
string? error = null)
|
||||||
|
{
|
||||||
|
return new HookLifecycleEventDto
|
||||||
|
{
|
||||||
|
Type = "hook-lifecycle",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
HookInvocationId = hookInvocationId,
|
||||||
|
HookType = hookType,
|
||||||
|
Phase = phase,
|
||||||
|
Input = input,
|
||||||
|
Success = success,
|
||||||
|
Output = output,
|
||||||
|
Error = error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private AssistantUsageEventDto CreateAssistantUsageEvent(
|
||||||
|
AgentIdentity agent,
|
||||||
|
ProviderAssistantUsageEvent data)
|
||||||
|
{
|
||||||
|
return new AssistantUsageEventDto
|
||||||
|
{
|
||||||
|
Type = "assistant-usage",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
Model = data.Model,
|
||||||
|
InputTokens = data.InputTokens,
|
||||||
|
OutputTokens = data.OutputTokens,
|
||||||
|
CacheReadTokens = data.CacheReadTokens,
|
||||||
|
CacheWriteTokens = data.CacheWriteTokens,
|
||||||
|
Cost = data.Cost,
|
||||||
|
Duration = data.Duration,
|
||||||
|
TotalNanoAiu = data.TotalNanoAiu,
|
||||||
|
QuotaSnapshots = data.QuotaSnapshots,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, ProviderSessionUsageEvent data)
|
||||||
|
{
|
||||||
|
return new SessionUsageEventDto
|
||||||
|
{
|
||||||
|
Type = "session-usage",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
TokenLimit = data.TokenLimit,
|
||||||
|
CurrentTokens = data.CurrentTokens,
|
||||||
|
MessagesLength = data.MessagesLength,
|
||||||
|
SystemTokens = data.SystemTokens,
|
||||||
|
ConversationTokens = data.ConversationTokens,
|
||||||
|
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
|
||||||
|
IsInitial = data.IsInitial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private SessionCompactionEventDto CreateCompactionStartEvent(
|
||||||
|
AgentIdentity agent,
|
||||||
|
ProviderSessionCompactionStartEvent data)
|
||||||
|
{
|
||||||
|
return new SessionCompactionEventDto
|
||||||
|
{
|
||||||
|
Type = "session-compaction",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
Phase = "start",
|
||||||
|
SystemTokens = data.SystemTokens,
|
||||||
|
ConversationTokens = data.ConversationTokens,
|
||||||
|
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private SessionCompactionEventDto CreateCompactionCompleteEvent(
|
||||||
|
AgentIdentity agent,
|
||||||
|
ProviderSessionCompactionCompleteEvent data)
|
||||||
|
{
|
||||||
|
return new SessionCompactionEventDto
|
||||||
|
{
|
||||||
|
Type = "session-compaction",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
Phase = "complete",
|
||||||
|
Success = data.Success,
|
||||||
|
Error = data.Error,
|
||||||
|
SystemTokens = data.SystemTokens,
|
||||||
|
ConversationTokens = data.ConversationTokens,
|
||||||
|
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
|
||||||
|
PreCompactionTokens = data.PreCompactionTokens,
|
||||||
|
PostCompactionTokens = data.PostCompactionTokens,
|
||||||
|
PreCompactionMessagesLength = data.PreCompactionMessagesLength,
|
||||||
|
MessagesRemoved = data.MessagesRemoved,
|
||||||
|
TokensRemoved = data.TokensRemoved,
|
||||||
|
SummaryContent = data.SummaryContent,
|
||||||
|
CheckpointNumber = data.CheckpointNumber,
|
||||||
|
CheckpointPath = data.CheckpointPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private PendingMessagesModifiedEventDto CreatePendingMessagesModifiedEvent(AgentIdentity agent)
|
||||||
|
{
|
||||||
|
return new PendingMessagesModifiedEventDto
|
||||||
|
{
|
||||||
|
Type = "pending-messages-modified",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using Aryx.AgentHost.Contracts;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace Aryx.AgentHost.Services;
|
||||||
|
|
||||||
|
internal static class WorkflowOrchestrationFactory
|
||||||
|
{
|
||||||
|
public static HandoffWorkflowBuilder CreateHandoffWorkflowBuilder(
|
||||||
|
AIAgent entryAgent,
|
||||||
|
HandoffModeSettingsDto? settings = null)
|
||||||
|
{
|
||||||
|
HandoffModeSettingsDto effectiveSettings = settings ?? new HandoffModeSettingsDto();
|
||||||
|
HandoffWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
||||||
|
.WithToolCallFilteringBehavior(MapHandoffToolCallFiltering(effectiveSettings.ToolCallFiltering))
|
||||||
|
.WithHandoffInstructions(NormalizeOptionalString(effectiveSettings.HandoffInstructions)
|
||||||
|
?? HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
||||||
|
|
||||||
|
if (effectiveSettings.ReturnToPrevious)
|
||||||
|
{
|
||||||
|
builder = builder.EnableReturnToPrevious();
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Workflow CreateHandoffWorkflow(
|
||||||
|
WorkflowDefinitionDto workflowDefinition,
|
||||||
|
IReadOnlyList<AIAgent> agents)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(workflowDefinition);
|
||||||
|
ArgumentNullException.ThrowIfNull(agents);
|
||||||
|
|
||||||
|
IReadOnlyList<WorkflowNodeDto> agentNodes = workflowDefinition.GetAgentNodes();
|
||||||
|
Dictionary<string, AIAgent> agentsById = CreateAgentMap(agents);
|
||||||
|
WorkflowNodeDto triageNode = ResolveTriageAgentNode(workflowDefinition, agentNodes);
|
||||||
|
AIAgent triageAgent = ResolveAgentForNode(triageNode, agentsById);
|
||||||
|
HandoffModeSettingsDto? settings = workflowDefinition.Settings.ModeSettings?.Handoff;
|
||||||
|
HandoffWorkflowBuilder builder = CreateHandoffWorkflowBuilder(triageAgent, settings);
|
||||||
|
|
||||||
|
List<WorkflowNodeDto> specialistNodes = agentNodes
|
||||||
|
.Where(node => !string.Equals(node.Id, triageNode.Id, StringComparison.Ordinal))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (specialistNodes.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Handoff workflows require at least one specialist agent in addition to the triage agent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (WorkflowNodeDto specialistNode in specialistNodes)
|
||||||
|
{
|
||||||
|
AIAgent specialistAgent = ResolveAgentForNode(specialistNode, agentsById);
|
||||||
|
builder.WithHandoff(
|
||||||
|
triageAgent,
|
||||||
|
specialistAgent,
|
||||||
|
HandoffWorkflowGuidance.CreateForwardReason(specialistNode));
|
||||||
|
|
||||||
|
if (settings?.ReturnToPrevious != true)
|
||||||
|
{
|
||||||
|
builder.WithHandoff(
|
||||||
|
specialistAgent,
|
||||||
|
triageAgent,
|
||||||
|
HandoffWorkflowGuidance.CreateReturnReason(triageNode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GroupChatWorkflowBuilder CreateGroupChatWorkflowBuilder(
|
||||||
|
WorkflowDefinitionDto workflowDefinition,
|
||||||
|
IReadOnlyList<AIAgent> agents)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(workflowDefinition);
|
||||||
|
ArgumentNullException.ThrowIfNull(agents);
|
||||||
|
|
||||||
|
int maxRounds = ResolveGroupChatMaxRounds(workflowDefinition);
|
||||||
|
GroupChatWorkflowBuilder builder = AgentWorkflowBuilder.CreateGroupChatBuilderWith(
|
||||||
|
participants => new RoundRobinGroupChatManager(participants)
|
||||||
|
{
|
||||||
|
MaximumIterationCount = maxRounds,
|
||||||
|
})
|
||||||
|
.AddParticipants(agents);
|
||||||
|
|
||||||
|
string? name = NormalizeOptionalString(workflowDefinition.Name);
|
||||||
|
if (name is not null)
|
||||||
|
{
|
||||||
|
builder.WithName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
string? description = NormalizeOptionalString(workflowDefinition.Description);
|
||||||
|
if (description is not null)
|
||||||
|
{
|
||||||
|
builder.WithDescription(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Workflow CreateGroupChatWorkflow(
|
||||||
|
WorkflowDefinitionDto workflowDefinition,
|
||||||
|
IReadOnlyList<AIAgent> agents)
|
||||||
|
{
|
||||||
|
return CreateGroupChatWorkflowBuilder(workflowDefinition, agents).Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, AIAgent> CreateAgentMap(IReadOnlyList<AIAgent> agents)
|
||||||
|
{
|
||||||
|
Dictionary<string, AIAgent> agentMap = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (AIAgent agent in agents)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(agent.Id))
|
||||||
|
{
|
||||||
|
agentMap[agent.Id] = agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(agent.Name))
|
||||||
|
{
|
||||||
|
agentMap[agent.Name] = agent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return agentMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AIAgent ResolveAgentForNode(
|
||||||
|
WorkflowNodeDto node,
|
||||||
|
IReadOnlyDictionary<string, AIAgent> agentsById)
|
||||||
|
{
|
||||||
|
string agentId = node.GetAgentId();
|
||||||
|
if (agentsById.TryGetValue(agentId, out AIAgent? agent))
|
||||||
|
{
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
string agentName = node.GetAgentName();
|
||||||
|
if (agentsById.TryGetValue(agentName, out agent))
|
||||||
|
{
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Workflow agent \"{agentId}\" could not be resolved from the constructed agents.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkflowNodeDto ResolveTriageAgentNode(
|
||||||
|
WorkflowDefinitionDto workflowDefinition,
|
||||||
|
IReadOnlyList<WorkflowNodeDto> agentNodes)
|
||||||
|
{
|
||||||
|
if (agentNodes.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Handoff workflows require at least one agent node.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string? triageAgentNodeId = NormalizeOptionalString(workflowDefinition.Settings.ModeSettings?.Handoff?.TriageAgentNodeId);
|
||||||
|
if (triageAgentNodeId is null)
|
||||||
|
{
|
||||||
|
return agentNodes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkflowNodeDto? triageNode = agentNodes.FirstOrDefault(node => string.Equals(node.Id, triageAgentNodeId, StringComparison.Ordinal));
|
||||||
|
return triageNode ?? throw new InvalidOperationException(
|
||||||
|
$"Handoff workflow triage agent node \"{triageAgentNodeId}\" was not found in the workflow graph.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HandoffToolCallFilteringBehavior MapHandoffToolCallFiltering(string? value)
|
||||||
|
{
|
||||||
|
return value?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"none" => HandoffToolCallFilteringBehavior.None,
|
||||||
|
"all" => HandoffToolCallFilteringBehavior.All,
|
||||||
|
_ => HandoffToolCallFilteringBehavior.HandoffOnly,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveGroupChatMaxRounds(WorkflowDefinitionDto workflowDefinition)
|
||||||
|
{
|
||||||
|
int? configuredMaxRounds = workflowDefinition.Settings.ModeSettings?.GroupChat?.MaxRounds;
|
||||||
|
if (configuredMaxRounds is > 0)
|
||||||
|
{
|
||||||
|
return configuredMaxRounds.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflowDefinition.Settings.MaxIterations is > 0)
|
||||||
|
{
|
||||||
|
return workflowDefinition.Settings.MaxIterations.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeOptionalString(string? value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,7 +141,7 @@ internal sealed class WorkflowRunner
|
|||||||
throw new InvalidOperationException($"Workflow node \"{node.Id}\" references unknown agent \"{agentId}\".");
|
throw new InvalidOperationException($"Workflow node \"{node.Id}\" references unknown agent \"{agentId}\".");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new WorkflowNodeRoute(agent.BindAsExecutor(CopilotAgentBundle.CreateAgentHostOptions()));
|
return new WorkflowNodeRoute(agent.BindAsExecutor(AgentHostOptionsFactory.CreateDefault()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(node.Kind, "code-executor", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(node.Kind, "code-executor", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
Reference in New Issue
Block a user