diff --git a/AGENTS.md b/AGENTS.md index b5d39b3..8107f91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,5 +52,7 @@ These instructions apply to any automated or semi-automated agent working in thi - Use Bun for dependency management and script execution. - Prefer repository-local tooling over global machine state whenever possible. - Keep changes focused and reviewable. Avoid mixing unrelated concerns into a single change. +- When the user asks only for a plan, stay in planning flow: analyze the codebase, clarify scope as needed, and write or update the plan, but do not begin implementation until the user explicitly asks you to start the work. +- Do not trigger workflow or mode transitions that implicitly approve or begin implementation unless the user has explicitly requested that transition. - Always commit completed repository changes before handing work off. If unrelated pre-existing changes are present in the worktree, stop and ask the user how to proceed before creating the commit. - Do not mark work as done until both the implementation and its verification are complete. diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs new file mode 100644 index 0000000..ae29079 --- /dev/null +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs @@ -0,0 +1,161 @@ +using GitHub.Copilot.SDK; +using Eryx.AgentHost.Contracts; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.GitHub.Copilot; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Agents.AI.Workflows.Specialized; +using Microsoft.Extensions.AI; + +namespace Eryx.AgentHost.Services; + +internal sealed class CopilotAgentBundle : IAsyncDisposable +{ + private readonly List _disposables = []; + + private CopilotAgentBundle(IReadOnlyList agents) + { + Agents = agents; + } + + public IReadOnlyList Agents { get; } + + public static async Task CreateAsync( + RunTurnCommandDto command, + Func> onPermissionRequest, + CancellationToken cancellationToken) + { + List disposables = []; + List agents = []; + CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(); + bool isScratchpad = string.Equals(command.WorkspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase); + SessionToolingBundle? toolingBundle = isScratchpad + ? null + : await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken) + .ConfigureAwait(false); + + if (toolingBundle is not null) + { + disposables.Add(toolingBundle); + } + + foreach ((PatternAgentDefinitionDto definition, int agentIndex) in command.Pattern.Agents.Select((definition, index) => (definition, index))) + { + CopilotClient client = new(clientOptions); + await client.StartAsync(cancellationToken).ConfigureAwait(false); + + SessionConfig sessionConfig = new() + { + Model = definition.Model, + ReasoningEffort = definition.ReasoningEffort, + SystemMessage = new SystemMessageConfig + { + Content = AgentInstructionComposer.Compose(command.Pattern, definition, agentIndex, command.WorkspaceKind), + }, + WorkingDirectory = command.ProjectPath, + OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation), + Streaming = true, + }; + + if (isScratchpad) + { + sessionConfig.AvailableTools = []; + } + else if (toolingBundle is not null) + { + if (toolingBundle.McpServers.Count > 0) + { + sessionConfig.McpServers = toolingBundle.McpServers; + } + + if (toolingBundle.Tools.Count > 0) + { + sessionConfig.Tools = toolingBundle.Tools.ToList(); + } + } + + GitHubCopilotAgent agent = new( + client, + sessionConfig, + ownsClient: true, + id: definition.Id, + name: definition.Name, + description: definition.Description); + + agents.Add(agent); + disposables.Add(agent); + } + + CopilotAgentBundle bundle = new(agents); + bundle._disposables.AddRange(disposables); + return bundle; + } + + public Workflow BuildWorkflow(PatternDefinitionDto pattern) + { + return pattern.Mode switch + { + "single" => AgentWorkflowBuilder.BuildSequential(pattern.Name, Agents), + "sequential" => AgentWorkflowBuilder.BuildSequential(pattern.Name, Agents), + "concurrent" => AgentWorkflowBuilder.BuildConcurrent(pattern.Name, Agents), + "handoff" => BuildHandoffWorkflow(pattern), + "group-chat" => BuildGroupChatWorkflow(pattern), + "magentic" => throw new NotSupportedException( + pattern.UnavailabilityReason + ?? "Magentic orchestration is not yet supported in the .NET Agent Framework."), + _ => throw new NotSupportedException($"Unsupported orchestration mode '{pattern.Mode}'."), + }; + } + + public async ValueTask DisposeAsync() + { + foreach (IAsyncDisposable disposable in _disposables) + { + await disposable.DisposeAsync().ConfigureAwait(false); + } + } + + private Workflow BuildHandoffWorkflow(PatternDefinitionDto pattern) + { + AIAgent firstAgent = Agents[0]; + PatternAgentDefinitionDto triageDefinition = pattern.Agents[0]; + IReadOnlyList<(AIAgent Agent, PatternAgentDefinitionDto Definition)> specialists = + Agents.Skip(1) + .Zip(pattern.Agents.Skip(1), (agent, definition) => (agent, definition)) + .ToList(); + + HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(firstAgent) + .WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions()); + + foreach ((AIAgent specialist, PatternAgentDefinitionDto definition) in specialists) + { + builder = builder.WithHandoff( + firstAgent, + specialist, + HandoffWorkflowGuidance.CreateForwardReason(definition)); + } + + foreach ((AIAgent specialist, _) in specialists) + { + builder = builder.WithHandoff( + specialist, + firstAgent, + HandoffWorkflowGuidance.CreateReturnReason(triageDefinition)); + } + + return builder.Build(); + } + + private Workflow BuildGroupChatWorkflow(PatternDefinitionDto pattern) + { + int maximumIterations = pattern.MaxIterations <= 0 ? 5 : pattern.MaxIterations; + + return AgentWorkflowBuilder + .CreateGroupChatBuilderWith(agents => + new RoundRobinGroupChatManager(agents) + { + MaximumIterationCount = maximumIterations, + }) + .AddParticipants(Agents.ToArray()) + .Build(); + } +} diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotApprovalCoordinator.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotApprovalCoordinator.cs new file mode 100644 index 0000000..d42b912 --- /dev/null +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotApprovalCoordinator.cs @@ -0,0 +1,265 @@ +using System.Collections.Concurrent; +using GitHub.Copilot.SDK; +using Eryx.AgentHost.Contracts; + +namespace Eryx.AgentHost.Services; + +internal sealed class CopilotApprovalCoordinator +{ + private readonly ConcurrentDictionary _pendingApprovals = new(StringComparer.Ordinal); + + public Task ResolveApprovalAsync( + ResolveApprovalCommandDto command, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + if (string.IsNullOrWhiteSpace(command.ApprovalId)) + { + throw new InvalidOperationException("Approval ID is required."); + } + + if (!_pendingApprovals.TryGetValue(command.ApprovalId, out PendingApprovalRequest? pending)) + { + throw new InvalidOperationException($"Approval \"{command.ApprovalId}\" is not pending."); + } + + PermissionRequestResultKind decision = command.Decision.Trim().ToLowerInvariant() switch + { + "approved" => PermissionRequestResultKind.Approved, + "rejected" => PermissionRequestResultKind.DeniedInteractivelyByUser, + _ => throw new InvalidOperationException( + $"Unsupported approval decision \"{command.Decision}\"."), + }; + + if (!pending.Decision.TrySetResult(decision)) + { + throw new InvalidOperationException($"Approval \"{command.ApprovalId}\" is no longer pending."); + } + + return Task.CompletedTask; + } + + public async Task RequestApprovalAsync( + RunTurnCommandDto command, + PatternAgentDefinitionDto agent, + PermissionRequest request, + PermissionInvocation invocation, + IReadOnlyDictionary toolNamesByCallId, + Func onApproval, + CancellationToken cancellationToken) + { + TryGetApprovalToolName(request, toolNamesByCallId, out string? toolName); + + if (!RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName)) + { + return new PermissionRequestResult + { + Kind = PermissionRequestResultKind.Approved, + }; + } + + string approvalId = CreateApprovalRequestId(); + TaskCompletionSource decisionSource = + new(TaskCreationOptions.RunContinuationsAsynchronously); + PendingApprovalRequest pending = new( + command.RequestId, + command.SessionId, + approvalId, + decisionSource); + + if (!_pendingApprovals.TryAdd(approvalId, pending)) + { + throw new InvalidOperationException($"Approval \"{approvalId}\" is already pending."); + } + + try + { + await onApproval(BuildPermissionApprovalEvent(command, agent, request, invocation, approvalId, toolName)) + .ConfigureAwait(false); + + using CancellationTokenRegistration registration = cancellationToken.Register( + static state => + { + ((TaskCompletionSource)state!) + .TrySetCanceled(); + }, + decisionSource); + + PermissionRequestResultKind decision = await decisionSource.Task.ConfigureAwait(false); + return new PermissionRequestResult + { + Kind = decision, + }; + } + finally + { + _pendingApprovals.TryRemove(approvalId, out _); + } + } + + internal static ApprovalRequestedEventDto BuildPermissionApprovalEvent( + RunTurnCommandDto command, + PatternAgentDefinitionDto agent, + PermissionRequest request, + PermissionInvocation invocation, + string approvalId, + string? toolName) + { + string permissionKind = string.IsNullOrWhiteSpace(request.Kind) + ? "tool access" + : request.Kind.Trim(); + string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name; + string? sessionId = string.IsNullOrWhiteSpace(invocation.SessionId) + ? null + : invocation.SessionId.Trim(); + string? normalizedToolName = string.IsNullOrWhiteSpace(toolName) + ? null + : toolName.Trim(); + string? requestedUrl = request is PermissionRequestUrl urlRequest && !string.IsNullOrWhiteSpace(urlRequest.Url) + ? urlRequest.Url.Trim() + : null; + string title = normalizedToolName is null + ? $"Approve {permissionKind}" + : $"Approve {normalizedToolName}"; + string detail = normalizedToolName is null + ? $"{agentName} requested {permissionKind} permission" + : $"{agentName} requested {permissionKind} permission for tool \"{normalizedToolName}\""; + + if (requestedUrl is not null) + { + detail = $"{detail} to access \"{requestedUrl}\""; + } + + if (sessionId is not null) + { + detail = normalizedToolName is null + ? $"{detail} for Copilot session {sessionId}" + : $"{detail} in Copilot session {sessionId}"; + } + + detail = $"{detail}."; + + return new ApprovalRequestedEventDto + { + Type = "approval-requested", + RequestId = command.RequestId, + SessionId = command.SessionId, + ApprovalId = approvalId, + ApprovalKind = "tool-call", + AgentId = string.IsNullOrWhiteSpace(agent.Id) ? null : agent.Id, + AgentName = string.IsNullOrWhiteSpace(agentName) ? null : agentName, + ToolName = normalizedToolName, + PermissionKind = permissionKind, + Title = title, + Detail = detail, + }; + } + + internal static bool RequiresToolCallApproval( + ApprovalPolicyDto? approvalPolicy, + string agentId, + string? toolName) + { + if (approvalPolicy?.Rules is null || approvalPolicy.Rules.Count == 0) + { + return false; + } + + bool matchesCheckpoint = false; + foreach (ApprovalCheckpointRuleDto rule in approvalPolicy.Rules) + { + if (!string.Equals(rule.Kind, "tool-call", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (rule.AgentIds.Count == 0) + { + matchesCheckpoint = true; + break; + } + + if (rule.AgentIds.Any(candidate => + string.Equals(candidate, agentId, StringComparison.OrdinalIgnoreCase))) + { + matchesCheckpoint = true; + break; + } + } + + if (!matchesCheckpoint) + { + return false; + } + + if (string.IsNullOrWhiteSpace(toolName)) + { + return true; + } + + return !approvalPolicy.AutoApprovedToolNames.Any(candidate => + string.Equals(candidate, toolName, StringComparison.OrdinalIgnoreCase)); + } + + internal static bool TryGetApprovalToolName( + PermissionRequest request, + IReadOnlyDictionary? toolNamesByCallId, + out string? toolName) + { + toolName = request switch + { + PermissionRequestMcp mcp when !string.IsNullOrWhiteSpace(mcp.ToolName) => mcp.ToolName.Trim(), + PermissionRequestCustomTool customTool when !string.IsNullOrWhiteSpace(customTool.ToolName) => customTool.ToolName.Trim(), + PermissionRequestHook hook when !string.IsNullOrWhiteSpace(hook.ToolName) => hook.ToolName.Trim(), + _ => null, + }; + + if (!string.IsNullOrWhiteSpace(toolName)) + { + return true; + } + + string? toolCallId = NormalizeOptionalString(GetStringProperty(request, "ToolCallId")); + if (toolCallId is not null + && toolNamesByCallId is not null + && toolNamesByCallId.TryGetValue(toolCallId, out string? resolvedToolName) + && !string.IsNullOrWhiteSpace(resolvedToolName)) + { + toolName = resolvedToolName.Trim(); + return true; + } + + toolName = request switch + { + PermissionRequestUrl => "web_fetch", + _ => null, + }; + + return !string.IsNullOrWhiteSpace(toolName); + } + + internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName) + => TryGetApprovalToolName(request, toolNamesByCallId: null, out toolName); + + private static string CreateApprovalRequestId() + { + return $"approval-{Guid.NewGuid():N}"; + } + + private static string? GetStringProperty(object? instance, string propertyName) + { + return instance?.GetType().GetProperty(propertyName)?.GetValue(instance) as string; + } + + private static string? NormalizeOptionalString(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private sealed record PendingApprovalRequest( + string RequestId, + string SessionId, + string ApprovalId, + TaskCompletionSource Decision); +} diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotTurnExecutionState.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotTurnExecutionState.cs new file mode 100644 index 0000000..8b76753 --- /dev/null +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotTurnExecutionState.cs @@ -0,0 +1,104 @@ +using System.Collections.Concurrent; +using Eryx.AgentHost.Contracts; +using Microsoft.Extensions.AI; + +namespace Eryx.AgentHost.Services; + +internal sealed class CopilotTurnExecutionState +{ + private readonly RunTurnCommandDto _command; + private readonly HashSet _startedAgents = new(StringComparer.OrdinalIgnoreCase); + private readonly StreamingTranscriptBuffer _transcriptBuffer = new(); + private int _fallbackMessageIndex; + + public CopilotTurnExecutionState(RunTurnCommandDto command) + { + _command = command; + } + + public ConcurrentDictionary ToolNamesByCallId { get; } = new(StringComparer.Ordinal); + + public AgentIdentity? ActiveAgent { get; private set; } + + public List CompletedMessages { get; private set; } = []; + + public async Task EmitThinkingIfNeeded( + AgentIdentity agent, + Func onActivity) + { + ActiveAgent = agent; + + if (!_startedAgents.Add(agent.AgentId)) + { + return; + } + + await onActivity(new AgentActivityEventDto + { + Type = "agent-activity", + RequestId = _command.RequestId, + SessionId = _command.SessionId, + ActivityType = "thinking", + AgentId = agent.AgentId, + AgentName = agent.AgentName, + }).ConfigureAwait(false); + } + + public void ApplyActivity(AgentActivityEventDto activity) + { + if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal) + && !string.IsNullOrWhiteSpace(activity.AgentId) + && !string.IsNullOrWhiteSpace(activity.AgentName)) + { + ActiveAgent = new AgentIdentity(activity.AgentId, activity.AgentName); + } + } + + public string CreateMessageId(string? messageId) + { + return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}"; + } + + public (string MessageId, string AuthorName, string Content) 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; + } + } + + public void UpdateCompletedMessages( + IReadOnlyList allMessages, + IReadOnlyList inputMessages) + { + List newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(allMessages, inputMessages); + CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessages( + _command, + newMessages, + _transcriptBuffer.Snapshot(), + ActiveAgent); + } + + public IReadOnlyList FinalizeCompletedMessages() + { + if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0) + { + CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessages( + _command, + [], + _transcriptBuffer.Snapshot(), + ActiveAgent); + } + + return CompletedMessages; + } +} diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs index 31af9e4..27041db 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -1,29 +1,13 @@ -using System.Collections.Concurrent; -using System.Text; -using GitHub.Copilot.SDK; using Eryx.AgentHost.Contracts; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.GitHub.Copilot; using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Specialized; using Microsoft.Extensions.AI; namespace Eryx.AgentHost.Services; public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner { - private static readonly Type? HandoffTargetType = LoadType( - "Microsoft.Agents.AI.Workflows.Specialized.HandoffTarget, Microsoft.Agents.AI.Workflows"); - private static readonly Type? FunctionCallContentType = LoadType( - "Microsoft.Extensions.AI.FunctionCallContent, Microsoft.Extensions.AI.Abstractions"); - private static readonly Type? McpServerToolCallContentType = LoadType( - "Microsoft.Extensions.AI.McpServerToolCallContent, Microsoft.Extensions.AI.Abstractions"); - private static readonly Type? CodeInterpreterToolCallContentType = LoadType( - "Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions"); - private static readonly Type? ImageGenerationToolCallContentType = LoadType( - "Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions"); private readonly PatternValidator _patternValidator; - private readonly ConcurrentDictionary _pendingApprovals = new(StringComparer.Ordinal); + private readonly CopilotApprovalCoordinator _approvalCoordinator = new(); public CopilotWorkflowRunner(PatternValidator patternValidator) { @@ -43,892 +27,144 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner throw new InvalidOperationException(validationError.Message); } - ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); - await using AgentBundle bundle = await AgentBundle.CreateAsync( + CopilotTurnExecutionState state = new(command); + await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync( command, - (agent, request, invocation) => RequestApprovalAsync( + (agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync( command, agent, request, invocation, - toolNamesByCallId, + state.ToolNamesByCallId, onApproval, cancellationToken), cancellationToken); Workflow workflow = bundle.BuildWorkflow(command.Pattern); - List inputMessages = command.Messages.Select(ToChatMessage).ToList(); - - List segments = []; - int fallbackMessageIndex = 0; - List completedMessages = []; - AgentIdentity? activeAgent = null; - HashSet startedAgents = new(StringComparer.OrdinalIgnoreCase); + List inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList(); await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) { - if (evt is ExecutorInvokedEvent invoked - && AgentIdentityResolver.TryResolveKnownAgentIdentity( - command.Pattern, - invoked.ExecutorId, - out AgentIdentity invokedAgent)) - { - activeAgent = invokedAgent; - await EmitThinkingIfNeeded( - command, - invokedAgent, - startedAgents, - onActivity).ConfigureAwait(false); - } - else if (evt is RequestInfoEvent requestInfo) - { - AgentActivityEventDto? activity = TryCreateActivityFromRequest( - command, - requestInfo, - activeAgent, - toolNamesByCallId); - - if (activity is not null) - { - if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal) - && !string.IsNullOrWhiteSpace(activity.AgentId) - && !string.IsNullOrWhiteSpace(activity.AgentName)) - { - activeAgent = new AgentIdentity(activity.AgentId, activity.AgentName); - } - - await onActivity(activity).ConfigureAwait(false); - } - } - else if (evt is AgentResponseUpdateEvent update) - { - AgentIdentity? updateAgent = null; - string authorName = update.ExecutorId; - if (AgentIdentityResolver.TryResolveObservedAgentIdentity( - command.Pattern, - update.ExecutorId, - activeAgent, - out AgentIdentity resolvedUpdateAgent)) - { - updateAgent = resolvedUpdateAgent; - authorName = resolvedUpdateAgent.AgentName; - } - - if (updateAgent.HasValue) - { - activeAgent = updateAgent.Value; - await EmitThinkingIfNeeded( - command, - updateAgent.Value, - startedAgents, - onActivity).ConfigureAwait(false); - } - - if (string.IsNullOrEmpty(update.Update.Text)) - { - continue; - } - - string messageId = update.Update.MessageId ?? $"{command.RequestId}-delta-{fallbackMessageIndex++}"; - StreamingSegment segment = GetOrCreateSegment(segments, messageId, authorName); - segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), update.Update.Text)); - segment.SetAuthorName(authorName); - - await onDelta(new TurnDeltaEventDto - { - Type = "turn-delta", - RequestId = command.RequestId, - SessionId = command.SessionId, - MessageId = messageId, - AuthorName = authorName, - ContentDelta = update.Update.Text, - Content = segment.Content.ToString(), - }).ConfigureAwait(false); - } - else if (evt is ExecutorCompletedEvent completed - && AgentIdentityResolver.TryResolveObservedAgentIdentity( - command.Pattern, - completed.ExecutorId, - activeAgent, - out AgentIdentity completedAgent)) - { - if (activeAgent.HasValue - && string.Equals(activeAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal)) - { - activeAgent = null; - } - } - else if (evt is WorkflowOutputEvent outputEvent) - { - List allMessages = outputEvent.As>() ?? []; - List newMessages = SelectNewOutputMessages(allMessages, inputMessages); - completedMessages = ProjectCompletedMessages( - command, - newMessages, - segments.Select(segment => (segment.MessageId, segment.AuthorName, segment.Content.ToString())).ToList(), - activeAgent); - } + await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity) + .ConfigureAwait(false); } - if (completedMessages.Count == 0 && segments.Count > 0) - { - completedMessages = ProjectCompletedMessages( - command, - [], - segments.Select(segment => (segment.MessageId, segment.AuthorName, segment.Content.ToString())).ToList(), - activeAgent); - } - - return completedMessages; + return state.FinalizeCompletedMessages(); } public Task ResolveApprovalAsync( ResolveApprovalCommandDto command, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(command); - - if (string.IsNullOrWhiteSpace(command.ApprovalId)) - { - throw new InvalidOperationException("Approval ID is required."); - } - - if (!_pendingApprovals.TryGetValue(command.ApprovalId, out PendingApprovalRequest? pending)) - { - throw new InvalidOperationException($"Approval \"{command.ApprovalId}\" is not pending."); - } - - PermissionRequestResultKind decision = command.Decision.Trim().ToLowerInvariant() switch - { - "approved" => PermissionRequestResultKind.Approved, - "rejected" => PermissionRequestResultKind.DeniedInteractivelyByUser, - _ => throw new InvalidOperationException( - $"Unsupported approval decision \"{command.Decision}\"."), - }; - - if (!pending.Decision.TrySetResult(decision)) - { - throw new InvalidOperationException($"Approval \"{command.ApprovalId}\" is no longer pending."); - } - - return Task.CompletedTask; + return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken); } - private static AgentActivityEventDto CreateActivityEvent( + private static async Task HandleWorkflowEventAsync( RunTurnCommandDto command, - string activityType, - AgentIdentity agent, - AgentIdentity? sourceAgent = null, - string? toolName = null) - { - return new AgentActivityEventDto - { - Type = "agent-activity", - RequestId = command.RequestId, - SessionId = command.SessionId, - ActivityType = activityType, - AgentId = agent.AgentId, - AgentName = agent.AgentName, - SourceAgentId = sourceAgent?.AgentId, - SourceAgentName = sourceAgent?.AgentName, - ToolName = toolName, - }; - } - - private async Task RequestApprovalAsync( - RunTurnCommandDto command, - PatternAgentDefinitionDto agent, - PermissionRequest request, - PermissionInvocation invocation, - IReadOnlyDictionary toolNamesByCallId, - Func onApproval, - CancellationToken cancellationToken) - { - TryGetApprovalToolName(request, toolNamesByCallId, out string? toolName); - - if (!RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName)) - { - return new PermissionRequestResult - { - Kind = PermissionRequestResultKind.Approved, - }; - } - - string approvalId = CreateApprovalRequestId(); - TaskCompletionSource decisionSource = - new(TaskCreationOptions.RunContinuationsAsynchronously); - PendingApprovalRequest pending = new( - command.RequestId, - command.SessionId, - approvalId, - decisionSource); - - if (!_pendingApprovals.TryAdd(approvalId, pending)) - { - throw new InvalidOperationException($"Approval \"{approvalId}\" is already pending."); - } - - try - { - await onApproval(BuildPermissionApprovalEvent(command, agent, request, invocation, approvalId, toolName)) - .ConfigureAwait(false); - - using CancellationTokenRegistration registration = cancellationToken.Register( - static state => - { - ((TaskCompletionSource)state!) - .TrySetCanceled(); - }, - decisionSource); - - PermissionRequestResultKind decision = await decisionSource.Task.ConfigureAwait(false); - return new PermissionRequestResult - { - Kind = decision, - }; - } - finally - { - _pendingApprovals.TryRemove(approvalId, out _); - } - } - - private static AgentActivityEventDto? TryCreateActivityFromRequest( - RunTurnCommandDto command, - RequestInfoEvent requestInfo, - AgentIdentity? activeAgent, - ConcurrentDictionary toolNamesByCallId) - { - if (TryGetHandoffTarget(command.Pattern, requestInfo, out AgentIdentity handoffAgent)) - { - return CreateActivityEvent( - command, - activityType: "handoff", - agent: handoffAgent, - sourceAgent: activeAgent); - } - - if (!activeAgent.HasValue - || !TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId)) - { - return null; - } - - if (!string.IsNullOrWhiteSpace(toolCallId)) - { - toolNamesByCallId[toolCallId] = toolName; - } - - return CreateActivityEvent( - command, - activityType: "tool-calling", - agent: activeAgent.Value, - toolName: toolName); - } - - private static async Task EmitThinkingIfNeeded( - RunTurnCommandDto command, - AgentIdentity agent, - ISet startedAgents, + WorkflowEvent evt, + IReadOnlyList inputMessages, + CopilotTurnExecutionState state, + Func onDelta, Func onActivity) { - if (!startedAgents.Add(agent.AgentId)) + if (evt is ExecutorInvokedEvent invoked + && AgentIdentityResolver.TryResolveKnownAgentIdentity( + command.Pattern, + invoked.ExecutorId, + out AgentIdentity invokedAgent)) + { + await state.EmitThinkingIfNeeded(invokedAgent, onActivity).ConfigureAwait(false); + return; + } + + if (evt is RequestInfoEvent requestInfo) + { + AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( + command, + requestInfo, + state.ActiveAgent, + state.ToolNamesByCallId); + + if (activity is null) + { + return; + } + + state.ApplyActivity(activity); + await onActivity(activity).ConfigureAwait(false); + return; + } + + if (evt is AgentResponseUpdateEvent update) + { + await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onActivity).ConfigureAwait(false); + return; + } + + if (evt is ExecutorCompletedEvent completed + && AgentIdentityResolver.TryResolveObservedAgentIdentity( + command.Pattern, + completed.ExecutorId, + state.ActiveAgent, + out AgentIdentity completedAgent)) + { + state.ClearActiveAgentIfMatching(completedAgent); + return; + } + + if (evt is WorkflowOutputEvent outputEvent) + { + List allMessages = outputEvent.As>() ?? []; + state.UpdateCompletedMessages(allMessages, inputMessages); + } + } + + private static async Task HandleAgentResponseUpdateAsync( + RunTurnCommandDto command, + AgentResponseUpdateEvent update, + CopilotTurnExecutionState state, + Func onDelta, + Func onActivity) + { + AgentIdentity? updateAgent = null; + string authorName = update.ExecutorId; + if (AgentIdentityResolver.TryResolveObservedAgentIdentity( + command.Pattern, + update.ExecutorId, + state.ActiveAgent, + out AgentIdentity resolvedUpdateAgent)) + { + updateAgent = resolvedUpdateAgent; + authorName = resolvedUpdateAgent.AgentName; + } + + if (updateAgent.HasValue) + { + await state.EmitThinkingIfNeeded(updateAgent.Value, onActivity).ConfigureAwait(false); + } + + if (string.IsNullOrEmpty(update.Update.Text)) { return; } - await onActivity(CreateActivityEvent( - command, - activityType: "thinking", - agent: agent)).ConfigureAwait(false); - } + string messageId = state.CreateMessageId(update.Update.MessageId); + (string _, string currentAuthorName, string currentContent) = state.AppendDelta( + messageId, + authorName, + update.Update.Text); - private static bool TryGetHandoffTarget( - PatternDefinitionDto pattern, - RequestInfoEvent requestInfo, - out AgentIdentity agent) - { - agent = default; - if (!TryReadPortableValue(requestInfo.Request.Data, HandoffTargetType, out object? handoffTarget)) + await onDelta(new TurnDeltaEventDto { - return false; - } - - object? target = handoffTarget?.GetType().GetProperty("Target")?.GetValue(handoffTarget); - agent = AgentIdentityResolver.ResolveAgentIdentity( - pattern, - GetStringProperty(target, "Id"), - GetStringProperty(target, "Name")); - return !string.IsNullOrWhiteSpace(agent.AgentName); - } - - private static bool TryGetToolRequestInfo( - RequestInfoEvent requestInfo, - out string toolName, - out string? toolCallId) - { - if (TryReadPortableValue(requestInfo.Request.Data, FunctionCallContentType, out object? functionCall)) - { - toolName = GetStringProperty(functionCall, "Name") ?? "function"; - toolCallId = NormalizeOptionalString(GetStringProperty(functionCall, "CallId")); - return true; - } - - if (TryReadPortableValue(requestInfo.Request.Data, McpServerToolCallContentType, out object? mcpToolCall)) - { - toolName = GetStringProperty(mcpToolCall, "ToolName") - ?? GetStringProperty(mcpToolCall, "ServerName") - ?? string.Empty; - toolCallId = NormalizeOptionalString(GetStringProperty(mcpToolCall, "CallId")); - return !string.IsNullOrWhiteSpace(toolName); - } - - if (TryReadPortableValue(requestInfo.Request.Data, CodeInterpreterToolCallContentType, out object? codeInterpreterToolCall)) - { - toolName = "code interpreter"; - toolCallId = NormalizeOptionalString(GetStringProperty(codeInterpreterToolCall, "CallId")); - return true; - } - - if (TryReadPortableValue(requestInfo.Request.Data, ImageGenerationToolCallContentType, out _)) - { - toolName = "image generation"; - toolCallId = null; - return true; - } - - toolName = string.Empty; - toolCallId = null; - return false; - } - - internal static ApprovalRequestedEventDto BuildPermissionApprovalEvent( - RunTurnCommandDto command, - PatternAgentDefinitionDto agent, - PermissionRequest request, - PermissionInvocation invocation, - string approvalId, - string? toolName) - { - string permissionKind = string.IsNullOrWhiteSpace(request.Kind) - ? "tool access" - : request.Kind.Trim(); - string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name; - string? sessionId = string.IsNullOrWhiteSpace(invocation.SessionId) - ? null - : invocation.SessionId.Trim(); - string? normalizedToolName = string.IsNullOrWhiteSpace(toolName) - ? null - : toolName.Trim(); - string? requestedUrl = request is PermissionRequestUrl urlRequest && !string.IsNullOrWhiteSpace(urlRequest.Url) - ? urlRequest.Url.Trim() - : null; - string title = normalizedToolName is null - ? $"Approve {permissionKind}" - : $"Approve {normalizedToolName}"; - string detail = normalizedToolName is null - ? $"{agentName} requested {permissionKind} permission" - : $"{agentName} requested {permissionKind} permission for tool \"{normalizedToolName}\""; - - if (requestedUrl is not null) - { - detail = $"{detail} to access \"{requestedUrl}\""; - } - - if (sessionId is not null) - { - detail = normalizedToolName is null - ? $"{detail} for Copilot session {sessionId}" - : $"{detail} in Copilot session {sessionId}"; - } - - detail = $"{detail}."; - - return new ApprovalRequestedEventDto - { - Type = "approval-requested", + Type = "turn-delta", RequestId = command.RequestId, SessionId = command.SessionId, - ApprovalId = approvalId, - ApprovalKind = "tool-call", - AgentId = string.IsNullOrWhiteSpace(agent.Id) ? null : agent.Id, - AgentName = string.IsNullOrWhiteSpace(agentName) ? null : agentName, - ToolName = normalizedToolName, - PermissionKind = permissionKind, - Title = title, - Detail = detail, - }; + MessageId = messageId, + AuthorName = currentAuthorName, + ContentDelta = update.Update.Text, + Content = currentContent, + }).ConfigureAwait(false); } - - internal static bool RequiresToolCallApproval( - ApprovalPolicyDto? approvalPolicy, - string agentId, - string? toolName) - { - if (approvalPolicy?.Rules is null || approvalPolicy.Rules.Count == 0) - { - return false; - } - - bool matchesCheckpoint = false; - foreach (ApprovalCheckpointRuleDto rule in approvalPolicy.Rules) - { - if (!string.Equals(rule.Kind, "tool-call", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (rule.AgentIds.Count == 0) - { - matchesCheckpoint = true; - break; - } - - if (rule.AgentIds.Any(candidate => - string.Equals(candidate, agentId, StringComparison.OrdinalIgnoreCase))) - { - matchesCheckpoint = true; - break; - } - } - - if (!matchesCheckpoint) - { - return false; - } - - if (string.IsNullOrWhiteSpace(toolName)) - { - return true; - } - - return !approvalPolicy.AutoApprovedToolNames.Any(candidate => - string.Equals(candidate, toolName, StringComparison.OrdinalIgnoreCase)); - } - - internal static bool TryGetApprovalToolName( - PermissionRequest request, - IReadOnlyDictionary? toolNamesByCallId, - out string? toolName) - { - toolName = request switch - { - PermissionRequestMcp mcp when !string.IsNullOrWhiteSpace(mcp.ToolName) => mcp.ToolName.Trim(), - PermissionRequestCustomTool customTool when !string.IsNullOrWhiteSpace(customTool.ToolName) => customTool.ToolName.Trim(), - PermissionRequestHook hook when !string.IsNullOrWhiteSpace(hook.ToolName) => hook.ToolName.Trim(), - _ => null, - }; - - if (!string.IsNullOrWhiteSpace(toolName)) - { - return true; - } - - string? toolCallId = NormalizeOptionalString(GetStringProperty(request, "ToolCallId")); - if (toolCallId is not null - && toolNamesByCallId is not null - && toolNamesByCallId.TryGetValue(toolCallId, out string? resolvedToolName) - && !string.IsNullOrWhiteSpace(resolvedToolName)) - { - toolName = resolvedToolName.Trim(); - return true; - } - - toolName = request switch - { - PermissionRequestUrl => "web_fetch", - _ => null, - }; - - return !string.IsNullOrWhiteSpace(toolName); - } - - internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName) - => TryGetApprovalToolName(request, toolNamesByCallId: null, out toolName); - - private static string CreateApprovalRequestId() - { - return $"approval-{Guid.NewGuid():N}"; - } - - private static Type? LoadType(string assemblyQualifiedName) - { - return Type.GetType(assemblyQualifiedName, throwOnError: false); - } - - private static bool TryReadPortableValue(PortableValue portableValue, Type? targetType, out object? value) - { - value = null; - if (targetType is null || !portableValue.IsType(targetType)) - { - return false; - } - - value = portableValue.AsType(targetType); - return value is not null; - } - - private static string? GetStringProperty(object? instance, string propertyName) - { - return instance?.GetType().GetProperty(propertyName)?.GetValue(instance) as string; - } - - private static string? NormalizeOptionalString(string? value) - { - return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - } - - private static StreamingSegment GetOrCreateSegment(List segments, string messageId, string authorName) - { - StreamingSegment? existing = segments.LastOrDefault(segment => segment.MessageId == messageId); - if (existing is not null) - { - return existing; - } - - StreamingSegment created = new(messageId, authorName); - segments.Add(created); - return created; - } - - internal static List ProjectCompletedMessages( - RunTurnCommandDto command, - IReadOnlyList newMessages, - IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments, - AgentIdentity? fallbackAgent = null) - { - List mapped = []; - int segmentIndex = 0; - int fallbackOutputIndex = 0; - - foreach (ChatMessage message in newMessages.Where(message => message.Role != ChatRole.User)) - { - (string MessageId, string AuthorName, string Content)? segment = - segmentIndex < segments.Count ? segments[segmentIndex] : null; - string content = message.Text ?? segment?.Content ?? string.Empty; - if (string.IsNullOrWhiteSpace(content)) - { - continue; - } - - if (segment.HasValue) - { - segmentIndex++; - } - - fallbackOutputIndex++; - - mapped.Add(new ChatMessageDto - { - Id = segment?.MessageId ?? $"{command.RequestId}-final-{fallbackOutputIndex}", - Role = message.Role == ChatRole.System ? "system" : "assistant", - AuthorName = ResolveProjectedAuthorName( - command.Pattern, - message.AuthorName, - segment?.AuthorName, - fallbackAgent), - Content = content, - CreatedAt = DateTimeOffset.UtcNow.ToString("O"), - }); - } - - if (mapped.Count == 0 && segments.Count > 0) - { - mapped.AddRange(segments.Select(segment => new ChatMessageDto - { - Id = segment.MessageId, - Role = "assistant", - AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Pattern, segment.AuthorName), - Content = segment.Content, - CreatedAt = DateTimeOffset.UtcNow.ToString("O"), - })); - } - - return mapped; - } - - internal static List SelectNewOutputMessages( - IReadOnlyList outputMessages, - IReadOnlyList inputMessages) - { - if (outputMessages.Count == 0) - { - return []; - } - - if (inputMessages.Count == 0) - { - return outputMessages.ToList(); - } - - int overlapLength = FindOutputInputOverlapLength(outputMessages, inputMessages); - return outputMessages.Skip(overlapLength).ToList(); - } - - private static int FindOutputInputOverlapLength( - IReadOnlyList outputMessages, - IReadOnlyList inputMessages) - { - int maxOverlap = Math.Min(outputMessages.Count, inputMessages.Count); - - for (int overlapLength = maxOverlap; overlapLength > 0; overlapLength--) - { - int inputStart = inputMessages.Count - overlapLength; - bool matches = true; - - for (int index = 0; index < overlapLength; index++) - { - if (!ChatMessagesMatch(inputMessages[inputStart + index], outputMessages[index])) - { - matches = false; - break; - } - } - - if (matches) - { - return overlapLength; - } - } - - return 0; - } - - private static bool ChatMessagesMatch(ChatMessage inputMessage, ChatMessage outputMessage) - { - if (inputMessage.Role != outputMessage.Role) - { - return false; - } - - if (!string.Equals(inputMessage.Text, outputMessage.Text, StringComparison.Ordinal)) - { - return false; - } - - return string.IsNullOrWhiteSpace(inputMessage.AuthorName) - || string.IsNullOrWhiteSpace(outputMessage.AuthorName) - || string.Equals(inputMessage.AuthorName, outputMessage.AuthorName, StringComparison.Ordinal); - } - - private static string ResolveProjectedAuthorName( - PatternDefinitionDto pattern, - string? primaryIdentifier, - string? fallbackIdentifier, - AgentIdentity? fallbackAgent) - { - if (fallbackAgent.HasValue && AgentIdentityResolver.IsGenericAssistantIdentifier(primaryIdentifier)) - { - return fallbackAgent.Value.AgentName; - } - - return AgentIdentityResolver.ResolveDisplayAuthorName( - pattern, - primaryIdentifier, - fallbackIdentifier); - } - - private static ChatMessage ToChatMessage(ChatMessageDto message) - { - ChatMessage mapped = new(message.Role switch - { - "user" => ChatRole.User, - "system" => ChatRole.System, - _ => ChatRole.Assistant, - }, message.Content); - - if (!string.IsNullOrWhiteSpace(message.AuthorName)) - { - mapped.AuthorName = message.AuthorName; - } - - return mapped; - } - - private sealed class StreamingSegment - { - public StreamingSegment(string messageId, string authorName) - { - MessageId = messageId; - AuthorName = authorName; - } - - public string MessageId { get; } - - public string AuthorName { get; private set; } - - public StringBuilder Content { get; } = new(); - - public void SetContent(string value) - { - Content.Clear(); - Content.Append(value); - } - - public void SetAuthorName(string value) - { - AuthorName = value; - } - } - - private sealed class AgentBundle : IAsyncDisposable - { - private readonly List _disposables = []; - - private AgentBundle(IReadOnlyList agents) - { - Agents = agents; - } - - public IReadOnlyList Agents { get; } - - public static async Task CreateAsync( - RunTurnCommandDto command, - Func> onPermissionRequest, - CancellationToken cancellationToken) - { - List disposables = []; - List agents = []; - CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(); - bool isScratchpad = string.Equals(command.WorkspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase); - SessionToolingBundle? toolingBundle = isScratchpad - ? null - : await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken) - .ConfigureAwait(false); - - if (toolingBundle is not null) - { - disposables.Add(toolingBundle); - } - - foreach ((PatternAgentDefinitionDto definition, int agentIndex) in command.Pattern.Agents.Select((definition, index) => (definition, index))) - { - CopilotClient client = new(clientOptions); - await client.StartAsync(cancellationToken).ConfigureAwait(false); - - SessionConfig sessionConfig = new() - { - Model = definition.Model, - ReasoningEffort = definition.ReasoningEffort, - SystemMessage = new SystemMessageConfig - { - Content = AgentInstructionComposer.Compose(command.Pattern, definition, agentIndex, command.WorkspaceKind), - }, - WorkingDirectory = command.ProjectPath, - OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation), - Streaming = true, - }; - - if (isScratchpad) - { - sessionConfig.AvailableTools = []; - } - else if (toolingBundle is not null) - { - if (toolingBundle.McpServers.Count > 0) - { - sessionConfig.McpServers = toolingBundle.McpServers; - } - - if (toolingBundle.Tools.Count > 0) - { - sessionConfig.Tools = toolingBundle.Tools.ToList(); - } - } - - GitHubCopilotAgent agent = new( - client, - sessionConfig, - ownsClient: true, - id: definition.Id, - name: definition.Name, - description: definition.Description); - - agents.Add(agent); - disposables.Add(agent); - } - - AgentBundle bundle = new(agents); - bundle._disposables.AddRange(disposables); - return bundle; - } - - public Workflow BuildWorkflow(PatternDefinitionDto pattern) - { - return pattern.Mode switch - { - "single" => AgentWorkflowBuilder.BuildSequential(pattern.Name, Agents), - "sequential" => AgentWorkflowBuilder.BuildSequential(pattern.Name, Agents), - "concurrent" => AgentWorkflowBuilder.BuildConcurrent(pattern.Name, Agents), - "handoff" => BuildHandoffWorkflow(pattern), - "group-chat" => BuildGroupChatWorkflow(pattern), - "magentic" => throw new NotSupportedException( - pattern.UnavailabilityReason - ?? "Magentic orchestration is not yet supported in the .NET Agent Framework."), - _ => throw new NotSupportedException($"Unsupported orchestration mode '{pattern.Mode}'."), - }; - } - - public async ValueTask DisposeAsync() - { - foreach (IAsyncDisposable disposable in _disposables) - { - await disposable.DisposeAsync().ConfigureAwait(false); - } - } - - private Workflow BuildHandoffWorkflow(PatternDefinitionDto pattern) - { - AIAgent firstAgent = Agents[0]; - PatternAgentDefinitionDto triageDefinition = pattern.Agents[0]; - IReadOnlyList<(AIAgent Agent, PatternAgentDefinitionDto Definition)> specialists = - Agents.Skip(1) - .Zip(pattern.Agents.Skip(1), (agent, definition) => (agent, definition)) - .ToList(); - - HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(firstAgent) - .WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions()); - - foreach ((AIAgent specialist, PatternAgentDefinitionDto definition) in specialists) - { - builder = builder.WithHandoff( - firstAgent, - specialist, - HandoffWorkflowGuidance.CreateForwardReason(definition)); - } - - foreach ((AIAgent specialist, _) in specialists) - { - builder = builder.WithHandoff( - specialist, - firstAgent, - HandoffWorkflowGuidance.CreateReturnReason(triageDefinition)); - } - - return builder.Build(); - } - - private Workflow BuildGroupChatWorkflow(PatternDefinitionDto pattern) - { - int maximumIterations = pattern.MaxIterations <= 0 ? 5 : pattern.MaxIterations; - - return AgentWorkflowBuilder - .CreateGroupChatBuilderWith(agents => - new RoundRobinGroupChatManager(agents) - { - MaximumIterationCount = maximumIterations, - }) - .AddParticipants(Agents.ToArray()) - .Build(); - } - - } - - private sealed record PendingApprovalRequest( - string RequestId, - string SessionId, - string ApprovalId, - TaskCompletionSource Decision); } diff --git a/sidecar/src/Eryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs b/sidecar/src/Eryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs new file mode 100644 index 0000000..e034a6d --- /dev/null +++ b/sidecar/src/Eryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs @@ -0,0 +1,149 @@ +using System.Collections.Concurrent; +using Eryx.AgentHost.Contracts; +using Microsoft.Agents.AI.Workflows; + +namespace Eryx.AgentHost.Services; + +internal static class WorkflowRequestInfoInterpreter +{ + private static readonly Type? HandoffTargetType = LoadType( + "Microsoft.Agents.AI.Workflows.Specialized.HandoffTarget, Microsoft.Agents.AI.Workflows"); + private static readonly Type? FunctionCallContentType = LoadType( + "Microsoft.Extensions.AI.FunctionCallContent, Microsoft.Extensions.AI.Abstractions"); + private static readonly Type? McpServerToolCallContentType = LoadType( + "Microsoft.Extensions.AI.McpServerToolCallContent, Microsoft.Extensions.AI.Abstractions"); + private static readonly Type? CodeInterpreterToolCallContentType = LoadType( + "Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions"); + private static readonly Type? ImageGenerationToolCallContentType = LoadType( + "Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions"); + + public static AgentActivityEventDto? TryCreateActivityFromRequest( + RunTurnCommandDto command, + RequestInfoEvent requestInfo, + AgentIdentity? activeAgent, + ConcurrentDictionary toolNamesByCallId) + { + if (TryGetHandoffTarget(command.Pattern, requestInfo, out AgentIdentity handoffAgent)) + { + return new AgentActivityEventDto + { + Type = "agent-activity", + RequestId = command.RequestId, + SessionId = command.SessionId, + ActivityType = "handoff", + AgentId = handoffAgent.AgentId, + AgentName = handoffAgent.AgentName, + SourceAgentId = activeAgent?.AgentId, + SourceAgentName = activeAgent?.AgentName, + }; + } + + if (!activeAgent.HasValue + || !TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId)) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(toolCallId)) + { + toolNamesByCallId[toolCallId] = toolName; + } + + return new AgentActivityEventDto + { + Type = "agent-activity", + RequestId = command.RequestId, + SessionId = command.SessionId, + ActivityType = "tool-calling", + AgentId = activeAgent.Value.AgentId, + AgentName = activeAgent.Value.AgentName, + ToolName = toolName, + }; + } + + private static bool TryGetHandoffTarget( + PatternDefinitionDto pattern, + RequestInfoEvent requestInfo, + out AgentIdentity agent) + { + agent = default; + if (!TryReadPortableValue(requestInfo.Request.Data, HandoffTargetType, out object? handoffTarget)) + { + return false; + } + + object? target = handoffTarget?.GetType().GetProperty("Target")?.GetValue(handoffTarget); + agent = AgentIdentityResolver.ResolveAgentIdentity( + pattern, + GetStringProperty(target, "Id"), + GetStringProperty(target, "Name")); + return !string.IsNullOrWhiteSpace(agent.AgentName); + } + + private static bool TryGetToolRequestInfo( + RequestInfoEvent requestInfo, + out string toolName, + out string? toolCallId) + { + if (TryReadPortableValue(requestInfo.Request.Data, FunctionCallContentType, out object? functionCall)) + { + toolName = GetStringProperty(functionCall, "Name") ?? "function"; + toolCallId = NormalizeOptionalString(GetStringProperty(functionCall, "CallId")); + return true; + } + + if (TryReadPortableValue(requestInfo.Request.Data, McpServerToolCallContentType, out object? mcpToolCall)) + { + toolName = GetStringProperty(mcpToolCall, "ToolName") + ?? GetStringProperty(mcpToolCall, "ServerName") + ?? string.Empty; + toolCallId = NormalizeOptionalString(GetStringProperty(mcpToolCall, "CallId")); + return !string.IsNullOrWhiteSpace(toolName); + } + + if (TryReadPortableValue(requestInfo.Request.Data, CodeInterpreterToolCallContentType, out object? codeInterpreterToolCall)) + { + toolName = "code interpreter"; + toolCallId = NormalizeOptionalString(GetStringProperty(codeInterpreterToolCall, "CallId")); + return true; + } + + if (TryReadPortableValue(requestInfo.Request.Data, ImageGenerationToolCallContentType, out _)) + { + toolName = "image generation"; + toolCallId = null; + return true; + } + + toolName = string.Empty; + toolCallId = null; + return false; + } + + private static Type? LoadType(string assemblyQualifiedName) + { + return Type.GetType(assemblyQualifiedName, throwOnError: false); + } + + private static bool TryReadPortableValue(PortableValue portableValue, Type? targetType, out object? value) + { + value = null; + if (targetType is null || !portableValue.IsType(targetType)) + { + return false; + } + + value = portableValue.AsType(targetType); + return value is not null; + } + + private static string? GetStringProperty(object? instance, string propertyName) + { + return instance?.GetType().GetProperty(propertyName)?.GetValue(instance) as string; + } + + private static string? NormalizeOptionalString(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs b/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs new file mode 100644 index 0000000..1ab3dd6 --- /dev/null +++ b/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs @@ -0,0 +1,230 @@ +using System.Text; +using Eryx.AgentHost.Contracts; +using Microsoft.Extensions.AI; + +namespace Eryx.AgentHost.Services; + +internal static class WorkflowTranscriptProjector +{ + public static ChatMessage ToChatMessage(ChatMessageDto message) + { + ChatMessage mapped = new(message.Role switch + { + "user" => ChatRole.User, + "system" => ChatRole.System, + _ => ChatRole.Assistant, + }, message.Content); + + if (!string.IsNullOrWhiteSpace(message.AuthorName)) + { + mapped.AuthorName = message.AuthorName; + } + + return mapped; + } + + public static List ProjectCompletedMessages( + RunTurnCommandDto command, + IReadOnlyList newMessages, + IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments, + AgentIdentity? fallbackAgent = null) + { + List mapped = []; + int segmentIndex = 0; + int fallbackOutputIndex = 0; + string createdAt = DateTimeOffset.UtcNow.ToString("O"); + + foreach (ChatMessage message in newMessages.Where(message => message.Role != ChatRole.User)) + { + (string MessageId, string AuthorName, string Content)? segment = + segmentIndex < segments.Count ? segments[segmentIndex] : null; + string content = message.Text ?? segment?.Content ?? string.Empty; + if (string.IsNullOrWhiteSpace(content)) + { + continue; + } + + if (segment.HasValue) + { + segmentIndex++; + } + + fallbackOutputIndex++; + + mapped.Add(new ChatMessageDto + { + Id = segment?.MessageId ?? $"{command.RequestId}-final-{fallbackOutputIndex}", + Role = message.Role == ChatRole.System ? "system" : "assistant", + AuthorName = ResolveProjectedAuthorName( + command.Pattern, + message.AuthorName, + segment?.AuthorName, + fallbackAgent), + Content = content, + CreatedAt = createdAt, + }); + } + + if (mapped.Count == 0 && segments.Count > 0) + { + mapped.AddRange(segments.Select(segment => new ChatMessageDto + { + Id = segment.MessageId, + Role = "assistant", + AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Pattern, segment.AuthorName), + Content = segment.Content, + CreatedAt = createdAt, + })); + } + + return mapped; + } + + public static List SelectNewOutputMessages( + IReadOnlyList outputMessages, + IReadOnlyList inputMessages) + { + if (outputMessages.Count == 0) + { + return []; + } + + if (inputMessages.Count == 0) + { + return outputMessages.ToList(); + } + + int overlapLength = FindOutputInputOverlapLength(outputMessages, inputMessages); + return outputMessages.Skip(overlapLength).ToList(); + } + + private static int FindOutputInputOverlapLength( + IReadOnlyList outputMessages, + IReadOnlyList inputMessages) + { + int maxOverlap = Math.Min(outputMessages.Count, inputMessages.Count); + + for (int overlapLength = maxOverlap; overlapLength > 0; overlapLength--) + { + int inputStart = inputMessages.Count - overlapLength; + bool matches = true; + + for (int index = 0; index < overlapLength; index++) + { + if (!ChatMessagesMatch(inputMessages[inputStart + index], outputMessages[index])) + { + matches = false; + break; + } + } + + if (matches) + { + return overlapLength; + } + } + + return 0; + } + + private static bool ChatMessagesMatch(ChatMessage inputMessage, ChatMessage outputMessage) + { + if (inputMessage.Role != outputMessage.Role) + { + return false; + } + + if (!string.Equals(inputMessage.Text, outputMessage.Text, StringComparison.Ordinal)) + { + return false; + } + + return string.IsNullOrWhiteSpace(inputMessage.AuthorName) + || string.IsNullOrWhiteSpace(outputMessage.AuthorName) + || string.Equals(inputMessage.AuthorName, outputMessage.AuthorName, StringComparison.Ordinal); + } + + private static string ResolveProjectedAuthorName( + PatternDefinitionDto pattern, + string? primaryIdentifier, + string? fallbackIdentifier, + AgentIdentity? fallbackAgent) + { + if (fallbackAgent.HasValue && AgentIdentityResolver.IsGenericAssistantIdentifier(primaryIdentifier)) + { + return fallbackAgent.Value.AgentName; + } + + return AgentIdentityResolver.ResolveDisplayAuthorName( + pattern, + primaryIdentifier, + fallbackIdentifier); + } +} + +internal sealed class StreamingTranscriptBuffer +{ + private readonly List _segments = []; + + public int Count => _segments.Count; + + public (string MessageId, string AuthorName, string Content) AppendDelta( + string messageId, + string authorName, + string delta) + { + StreamingSegment segment = GetOrCreateSegment(messageId, authorName); + segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), delta)); + segment.SetAuthorName(authorName); + return segment.ToSnapshot(); + } + + public IReadOnlyList<(string MessageId, string AuthorName, string Content)> Snapshot() + { + return _segments.Select(segment => segment.ToSnapshot()).ToList(); + } + + private StreamingSegment GetOrCreateSegment(string messageId, string authorName) + { + StreamingSegment? existing = _segments.LastOrDefault(segment => segment.MessageId == messageId); + if (existing is not null) + { + return existing; + } + + StreamingSegment created = new(messageId, authorName); + _segments.Add(created); + return created; + } + + private sealed class StreamingSegment + { + public StreamingSegment(string messageId, string authorName) + { + MessageId = messageId; + AuthorName = authorName; + } + + public string MessageId { get; } + + public string AuthorName { get; private set; } + + public StringBuilder Content { get; } = new(); + + public void SetContent(string value) + { + Content.Clear(); + Content.Append(value); + } + + public void SetAuthorName(string value) + { + AuthorName = value; + } + + public (string MessageId, string AuthorName, string Content) ToSnapshot() + { + return (MessageId, AuthorName, Content.ToString()); + } + } +} diff --git a/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index 21ad95f..c8838fb 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -20,7 +20,7 @@ public sealed class CopilotWorkflowRunnerTests new(ChatRole.Assistant, "Hi there."), ]; - IReadOnlyList newMessages = CopilotWorkflowRunner.SelectNewOutputMessages( + IReadOnlyList newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages( outputMessages, inputMessages); @@ -43,7 +43,7 @@ public sealed class CopilotWorkflowRunnerTests new(ChatRole.Assistant, "Hi there."), ]; - IReadOnlyList newMessages = CopilotWorkflowRunner.SelectNewOutputMessages( + IReadOnlyList newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages( outputMessages, inputMessages); @@ -64,7 +64,7 @@ public sealed class CopilotWorkflowRunnerTests new(ChatRole.Assistant, "Hi there."), ]; - IReadOnlyList newMessages = CopilotWorkflowRunner.SelectNewOutputMessages( + IReadOnlyList newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages( outputMessages, inputMessages); @@ -94,7 +94,7 @@ public sealed class CopilotWorkflowRunnerTests }, }; - IReadOnlyList messages = CopilotWorkflowRunner.ProjectCompletedMessages( + IReadOnlyList messages = WorkflowTranscriptProjector.ProjectCompletedMessages( command, [], [ @@ -138,7 +138,7 @@ public sealed class CopilotWorkflowRunnerTests }, }; - IReadOnlyList messages = CopilotWorkflowRunner.ProjectCompletedMessages( + IReadOnlyList messages = WorkflowTranscriptProjector.ProjectCompletedMessages( command, [ new ChatMessage(ChatRole.Assistant, "Hello") @@ -177,7 +177,7 @@ public sealed class CopilotWorkflowRunnerTests }, }; - IReadOnlyList messages = CopilotWorkflowRunner.ProjectCompletedMessages( + IReadOnlyList messages = WorkflowTranscriptProjector.ProjectCompletedMessages( command, [ new ChatMessage(ChatRole.Assistant, "The button is in place.") @@ -214,7 +214,7 @@ public sealed class CopilotWorkflowRunnerTests }, }; - IReadOnlyList messages = CopilotWorkflowRunner.ProjectCompletedMessages( + IReadOnlyList messages = WorkflowTranscriptProjector.ProjectCompletedMessages( command, [ new ChatMessage(ChatRole.Assistant, string.Empty) @@ -234,6 +234,52 @@ public sealed class CopilotWorkflowRunnerTests Assert.Equal("Real content", message.Content); } + [Fact] + public void StreamingTranscriptBuffer_MergesUpdatesPerMessageId() + { + StreamingTranscriptBuffer buffer = new(); + + buffer.AppendDelta("msg-1", "Architect", "Hello"); + (string messageId, string authorName, string content) = buffer.AppendDelta("msg-1", "Architect", " world"); + + Assert.Equal("msg-1", messageId); + Assert.Equal("Architect", authorName); + Assert.Equal("Hello world", content); + Assert.Collection( + buffer.Snapshot(), + segment => + { + Assert.Equal("msg-1", segment.MessageId); + Assert.Equal("Architect", segment.AuthorName); + Assert.Equal("Hello world", segment.Content); + }); + } + + [Fact] + public void StreamingTranscriptBuffer_PreservesInsertionOrderAcrossMessages() + { + StreamingTranscriptBuffer buffer = new(); + + buffer.AppendDelta("msg-1", "Architect", "A"); + buffer.AppendDelta("msg-2", "Implementer", "B"); + buffer.AppendDelta("msg-1", "Architect", " plus"); + + Assert.Collection( + buffer.Snapshot(), + first => + { + Assert.Equal("msg-1", first.MessageId); + Assert.Equal("Architect", first.AuthorName); + Assert.Equal("A plus", first.Content); + }, + second => + { + Assert.Equal("msg-2", second.MessageId); + Assert.Equal("Implementer", second.AuthorName); + Assert.Equal("B", second.Content); + }); + } + [Fact] public void RequiresToolCallApproval_HonorsAutoApprovedToolNames() { @@ -250,18 +296,18 @@ public sealed class CopilotWorkflowRunnerTests AutoApprovedToolNames = ["lsp_ts_hover", "web_fetch"], }; - Assert.False(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-1", "lsp_ts_hover")); - Assert.False(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-1", "web_fetch")); - Assert.True(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-1", "lsp_ts_definition")); - Assert.True(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-1", null)); - Assert.False(CopilotWorkflowRunner.RequiresToolCallApproval(policy, "agent-2", "lsp_ts_definition")); + Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "lsp_ts_hover")); + Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "web_fetch")); + Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "lsp_ts_definition")); + Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", null)); + Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-2", "lsp_ts_definition")); } [Fact] public void TryGetApprovalToolName_ReadsMcpCustomAndHookRequests() { Assert.True( - CopilotWorkflowRunner.TryGetApprovalToolName( + CopilotApprovalCoordinator.TryGetApprovalToolName( new PermissionRequestMcp { Kind = "mcp", @@ -274,7 +320,7 @@ public sealed class CopilotWorkflowRunnerTests Assert.Equal("git.status", mcpToolName); Assert.True( - CopilotWorkflowRunner.TryGetApprovalToolName( + CopilotApprovalCoordinator.TryGetApprovalToolName( new PermissionRequestCustomTool { Kind = "custom tool", @@ -285,7 +331,7 @@ public sealed class CopilotWorkflowRunnerTests Assert.Equal("lsp_ts_hover", customToolName); Assert.True( - CopilotWorkflowRunner.TryGetApprovalToolName( + CopilotApprovalCoordinator.TryGetApprovalToolName( new PermissionRequestHook { Kind = "hook", @@ -297,7 +343,7 @@ public sealed class CopilotWorkflowRunnerTests Assert.Equal("web_fetch", hookToolName); Assert.False( - CopilotWorkflowRunner.TryGetApprovalToolName( + CopilotApprovalCoordinator.TryGetApprovalToolName( new PermissionRequestShell { Kind = "shell", @@ -324,7 +370,7 @@ public sealed class CopilotWorkflowRunnerTests }; Assert.True( - CopilotWorkflowRunner.TryGetApprovalToolName( + CopilotApprovalCoordinator.TryGetApprovalToolName( new PermissionRequestUrl { Kind = "url", @@ -337,7 +383,7 @@ public sealed class CopilotWorkflowRunnerTests Assert.Equal("web_fetch", urlToolName); Assert.True( - CopilotWorkflowRunner.TryGetApprovalToolName( + CopilotApprovalCoordinator.TryGetApprovalToolName( new PermissionRequestShell { Kind = "shell", @@ -355,7 +401,7 @@ public sealed class CopilotWorkflowRunnerTests Assert.Equal("shell", shellToolName); Assert.True( - CopilotWorkflowRunner.TryGetApprovalToolName( + CopilotApprovalCoordinator.TryGetApprovalToolName( new PermissionRequestRead { Kind = "read", @@ -372,7 +418,7 @@ public sealed class CopilotWorkflowRunnerTests public void TryGetApprovalToolName_FallsBackToWebFetchForUncorrelatedUrlRequests() { Assert.True( - CopilotWorkflowRunner.TryGetApprovalToolName( + CopilotApprovalCoordinator.TryGetApprovalToolName( new PermissionRequestUrl { Kind = "url", @@ -387,7 +433,7 @@ public sealed class CopilotWorkflowRunnerTests [Fact] public void BuildPermissionApprovalEvent_IncludesToolContextWhenKnown() { - ApprovalRequestedEventDto approvalEvent = CopilotWorkflowRunner.BuildPermissionApprovalEvent( + ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent( new RunTurnCommandDto { RequestId = "turn-1", @@ -415,7 +461,7 @@ public sealed class CopilotWorkflowRunnerTests [Fact] public void BuildPermissionApprovalEvent_IncludesRequestedUrlForUrlPermissions() { - ApprovalRequestedEventDto approvalEvent = CopilotWorkflowRunner.BuildPermissionApprovalEvent( + ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent( new RunTurnCommandDto { RequestId = "turn-1", @@ -443,6 +489,98 @@ public sealed class CopilotWorkflowRunnerTests Assert.Contains("https://example.com/docs", approvalEvent.Detail); } + [Fact] + public async Task RequestApprovalAsync_RaisesApprovalAndCompletesAfterResolution() + { + CopilotApprovalCoordinator coordinator = new(); + ApprovalRequestedEventDto? observedApproval = null; + RunTurnCommandDto command = CreateApprovalCommand(); + + Task pending = coordinator.RequestApprovalAsync( + command, + command.Pattern.Agents[0], + new PermissionRequestCustomTool + { + Kind = "custom tool", + ToolName = "lsp_ts_definition", + ToolDescription = "Go to definition", + }, + new PermissionInvocation + { + SessionId = "copilot-session-1", + }, + new Dictionary(StringComparer.Ordinal), + approval => + { + observedApproval = approval; + return Task.CompletedTask; + }, + CancellationToken.None); + + Assert.False(pending.IsCompleted); + Assert.NotNull(observedApproval); + + await coordinator.ResolveApprovalAsync( + new ResolveApprovalCommandDto + { + ApprovalId = observedApproval!.ApprovalId, + Decision = "approved", + }, + CancellationToken.None); + + PermissionRequestResult result = await pending; + Assert.Equal(PermissionRequestResultKind.Approved, result.Kind); + } + + [Fact] + public async Task RequestApprovalAsync_AutoApprovesToolsThatDoNotRequireApproval() + { + CopilotApprovalCoordinator coordinator = new(); + bool sawApproval = false; + RunTurnCommandDto command = CreateApprovalCommand(); + + PermissionRequestResult result = await coordinator.RequestApprovalAsync( + command, + command.Pattern.Agents[0], + new PermissionRequestCustomTool + { + Kind = "custom tool", + ToolName = "web_fetch", + ToolDescription = "Fetch documentation", + }, + new PermissionInvocation + { + SessionId = "copilot-session-1", + }, + new Dictionary(StringComparer.Ordinal), + approval => + { + sawApproval = true; + return Task.CompletedTask; + }, + CancellationToken.None); + + Assert.False(sawApproval); + Assert.Equal(PermissionRequestResultKind.Approved, result.Kind); + } + + [Fact] + public async Task ResolveApprovalAsync_RejectsUnknownApprovalIds() + { + CopilotApprovalCoordinator coordinator = new(); + + InvalidOperationException error = await Assert.ThrowsAsync(() => + coordinator.ResolveApprovalAsync( + new ResolveApprovalCommandDto + { + ApprovalId = "approval-missing", + Decision = "approved", + }, + CancellationToken.None)); + + Assert.Contains("is not pending", error.Message); + } + private static PatternAgentDefinitionDto CreateAgent(string id, string name) { return new PatternAgentDefinitionDto @@ -453,4 +591,36 @@ public sealed class CopilotWorkflowRunnerTests Instructions = "Help with the request.", }; } + + private static RunTurnCommandDto CreateApprovalCommand() + { + return new RunTurnCommandDto + { + RequestId = "turn-1", + SessionId = "session-1", + Pattern = new PatternDefinitionDto + { + Id = "pattern-1", + Name = "Approval Pattern", + Mode = "single", + Availability = "available", + ApprovalPolicy = new ApprovalPolicyDto + { + Rules = + [ + new ApprovalCheckpointRuleDto + { + Kind = "tool-call", + AgentIds = ["agent-1"], + }, + ], + AutoApprovedToolNames = ["web_fetch"], + }, + Agents = + [ + CreateAgent("agent-1", "Primary"), + ], + }, + }; + } } diff --git a/sidecar/tests/Eryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs new file mode 100644 index 0000000..d3e9162 --- /dev/null +++ b/sidecar/tests/Eryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs @@ -0,0 +1,218 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using Eryx.AgentHost.Contracts; +using Eryx.AgentHost.Services; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Eryx.AgentHost.Tests; + +public sealed class WorkflowRequestInfoInterpreterTests +{ + [Fact] + public void TryCreateActivityFromRequest_ReturnsToolCallingActivityForFunctionCalls() + { + ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); + RequestInfoEvent requestInfo = CreateRequestInfoEvent( + new FunctionCallContent("call-1", "view", new Dictionary())); + + AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( + CreateSingleAgentCommand(), + requestInfo, + new AgentIdentity("agent-1", "Primary"), + toolNamesByCallId); + + Assert.NotNull(activity); + Assert.Equal("tool-calling", activity.ActivityType); + Assert.Equal("agent-1", activity.AgentId); + Assert.Equal("Primary", activity.AgentName); + Assert.Equal("view", activity.ToolName); + Assert.Equal("view", toolNamesByCallId["call-1"]); + } + + [Fact] + public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName() + { + ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); + RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateCodeInterpreterToolCall("call-1")); + + AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( + CreateSingleAgentCommand(), + requestInfo, + new AgentIdentity("agent-1", "Primary"), + toolNamesByCallId); + + Assert.NotNull(activity); + Assert.Equal("tool-calling", activity.ActivityType); + Assert.Equal("code interpreter", activity.ToolName); + Assert.Equal("code interpreter", toolNamesByCallId["call-1"]); + } + + [Fact] + public void TryCreateActivityFromRequest_MapsImageGenerationCallsWithoutTrackingCallId() + { + ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); + RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateImageGenerationToolCall()); + + AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( + CreateSingleAgentCommand(), + requestInfo, + new AgentIdentity("agent-1", "Primary"), + toolNamesByCallId); + + Assert.NotNull(activity); + Assert.Equal("tool-calling", activity.ActivityType); + Assert.Equal("image generation", activity.ToolName); + Assert.Empty(toolNamesByCallId); + } + + [Fact] + public void TryCreateActivityFromRequest_ReturnsHandoffActivityForKnownTargets() + { + ConcurrentDictionary toolNamesByCallId = new(StringComparer.Ordinal); + RequestInfoEvent requestInfo = CreateRequestInfoEvent( + CreateHandoffTarget("agent-handoff-ux", "UX Specialist")); + + AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( + CreateHandoffCommand(), + requestInfo, + new AgentIdentity("agent-handoff-triage", "Triage"), + toolNamesByCallId); + + Assert.NotNull(activity); + Assert.Equal("handoff", activity.ActivityType); + Assert.Equal("agent-handoff-ux", activity.AgentId); + Assert.Equal("UX Specialist", activity.AgentName); + Assert.Equal("agent-handoff-triage", activity.SourceAgentId); + Assert.Equal("Triage", activity.SourceAgentName); + Assert.Null(activity.ToolName); + Assert.Empty(toolNamesByCallId); + } + + private static RunTurnCommandDto CreateSingleAgentCommand() + { + return new RunTurnCommandDto + { + RequestId = "turn-1", + SessionId = "session-1", + Pattern = new PatternDefinitionDto + { + Id = "pattern-single", + Name = "Single Agent", + Mode = "single", + Availability = "available", + Agents = + [ + CreateAgent("agent-1", "Primary"), + ], + }, + }; + } + + private static RunTurnCommandDto CreateHandoffCommand() + { + return new RunTurnCommandDto + { + RequestId = "turn-1", + SessionId = "session-1", + Pattern = new PatternDefinitionDto + { + Id = "pattern-handoff", + Name = "Handoff Flow", + Mode = "handoff", + Availability = "available", + Agents = + [ + CreateAgent("agent-handoff-triage", "Triage"), + CreateAgent("agent-handoff-ux", "UX Specialist"), + ], + }, + }; + } + + private static PatternAgentDefinitionDto CreateAgent(string id, string name) + { + return new PatternAgentDefinitionDto + { + Id = id, + Name = name, + Model = "gpt-5.4", + Instructions = "Help with the request.", + }; + } + + private static RequestInfoEvent CreateRequestInfoEvent(object payload) + { + RequestPort port = RequestPort.Create("test-port"); + ExternalRequest request = ExternalRequest.Create(port, payload, "request-1"); + return new RequestInfoEvent(request); + } + + private static object CreateCodeInterpreterToolCall(string callId) + { + Type type = Type.GetType( + "Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions", + throwOnError: true)!; + object instance = Activator.CreateInstance(type)!; + type.GetProperty("CallId")!.SetValue(instance, callId); + return instance; + } + + private static object CreateImageGenerationToolCall() + { + Type type = Type.GetType( + "Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions", + throwOnError: true)!; + return Activator.CreateInstance(type)!; + } + + private static object CreateHandoffTarget(string id, string name) + { + Type type = Type.GetType( + "Microsoft.Agents.AI.Workflows.Specialized.HandoffTarget, Microsoft.Agents.AI.Workflows", + throwOnError: true)!; + return Activator.CreateInstance(type, CreateChatClientAgent(id, name), "Handle the UX work.")!; + } + + private static ChatClientAgent CreateChatClientAgent(string id, string name) + { + return new ChatClientAgent( + new StubChatClient(), + id, + name, + "Stub agent for handoff tests.", + [], + null!, + null!); + } + + private sealed class StubChatClient : IChatClient + { + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options, + [EnumeratorCancellation] + CancellationToken cancellationToken) + { + yield break; + } + + public object? GetService(Type serviceType, object? serviceKey) + { + return null; + } + + public void Dispose() + { + } + } +}