refactor: generalize sidecar workflow runner

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-08 11:35:50 +02:00
co-authored by Copilot
parent e46193ae53
commit 0e2f9b8ae5
15 changed files with 1912 additions and 1548 deletions
@@ -1,669 +1,24 @@
using System.Collections.Concurrent;
using Aryx.AgentHost.Contracts;
using Microsoft.Extensions.AI;
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)
: 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(
IReadOnlyList<ChatMessage> allMessages,
IReadOnlyList<ChatMessage> inputMessages)
{
List<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(allMessages, inputMessages);
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
_command,
newMessages,
_transcriptBuffer.Snapshot(),
ActiveAgent);
base.UpdateCompletedMessages(allMessages, inputMessages, CopilotTranscriptProjector.Instance);
}
public IReadOnlyList<ChatMessageDto> FinalizeCompletedMessages()
{
if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0)
{
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,
};
return base.FinalizeCompletedMessages(CopilotTranscriptProjector.Instance);
}
}