diff --git a/sidecar/src/Eryx.AgentHost/Services/AgentIdentityResolver.cs b/sidecar/src/Eryx.AgentHost/Services/AgentIdentityResolver.cs index 77f47a4..109cc4a 100644 --- a/sidecar/src/Eryx.AgentHost/Services/AgentIdentityResolver.cs +++ b/sidecar/src/Eryx.AgentHost/Services/AgentIdentityResolver.cs @@ -7,25 +7,17 @@ internal readonly record struct AgentIdentity(string AgentId, string AgentName); internal static class AgentIdentityResolver { + private const string GenericAssistantIdentifier = "assistant"; + public static bool TryResolveKnownAgentIdentity( PatternDefinitionDto pattern, string? agentIdentifier, out AgentIdentity agent) { agent = default; - if (string.IsNullOrWhiteSpace(agentIdentifier)) - { - return false; - } - - PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentIdentifier); - if (match is null - && IsGenericAssistantIdentifier(agentIdentifier) - && pattern.Agents.Count == 1) - { - match = pattern.Agents[0]; - } + PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentIdentifier) + ?? ResolveSingleAgentAssistantAlias(pattern, agentIdentifier); if (match is null) { return false; @@ -62,30 +54,12 @@ internal static class AgentIdentityResolver string? agentName) { PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentId) - ?? FindKnownAgent(pattern, agentName); + ?? FindKnownAgent(pattern, agentName) + ?? ResolveSingleAgentAssistantAlias(pattern, agentId, agentName); - if (match is null - && pattern.Agents.Count == 1 - && (IsGenericAssistantIdentifier(agentId) || IsGenericAssistantIdentifier(agentName))) - { - match = pattern.Agents[0]; - } - - if (match is not null) - { - return ToAgentIdentity(match); - } - - string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId) - ? agentId - : agentName ?? "agent"; - - if (!string.IsNullOrWhiteSpace(agentName)) - { - return new AgentIdentity(resolvedAgentId, agentName); - } - - return new AgentIdentity(resolvedAgentId, resolvedAgentId); + return match is not null + ? ToAgentIdentity(match) + : CreateFallbackIdentity(agentId, agentName); } public static string ResolveDisplayAuthorName( @@ -113,7 +87,24 @@ internal static class AgentIdentityResolver return fallbackIdentifier; } - return "assistant"; + return GenericAssistantIdentifier; + } + + internal static bool IsGenericAssistantIdentifier(string? candidate) + { + return string.Equals( + NormalizeComparisonKey(candidate), + GenericAssistantIdentifier, + StringComparison.Ordinal); + } + + private static PatternAgentDefinitionDto? ResolveSingleAgentAssistantAlias( + PatternDefinitionDto pattern, + params string?[] agentIdentifiers) + { + return pattern.Agents.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier) + ? pattern.Agents[0] + : null; } private static PatternAgentDefinitionDto? FindKnownAgent(PatternDefinitionDto pattern, string? candidate) @@ -128,6 +119,18 @@ internal static class AgentIdentityResolver string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name); } + private static AgentIdentity CreateFallbackIdentity(string? agentId, string? agentName) + { + string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId) + ? agentId + : agentName ?? "agent"; + string resolvedAgentName = !string.IsNullOrWhiteSpace(agentName) + ? agentName + : resolvedAgentId; + + return new AgentIdentity(resolvedAgentId, resolvedAgentName); + } + private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate) { if (string.IsNullOrWhiteSpace(candidate)) @@ -144,7 +147,6 @@ internal static class AgentIdentityResolver string normalizedCandidate = NormalizeComparisonKey(candidate); string normalizedId = NormalizeComparisonKey(agent.Id); string normalizedName = NormalizeComparisonKey(agent.Name); - if (normalizedCandidate.Length == 0) { return false; @@ -156,24 +158,11 @@ internal static class AgentIdentityResolver return true; } - if (normalizedId.Length > 0 - && normalizedCandidate.EndsWith(normalizedId, StringComparison.Ordinal)) - { - return true; - } - return normalizedId.Length > 0 && normalizedName.Length > 0 - && normalizedCandidate.Contains(normalizedId, StringComparison.Ordinal) - && normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal); - } - - internal static bool IsGenericAssistantIdentifier(string? candidate) - { - return string.Equals( - NormalizeComparisonKey(candidate), - "assistant", - StringComparison.Ordinal); + && (normalizedCandidate.EndsWith(normalizedId, StringComparison.Ordinal) + || normalizedCandidate.Contains(normalizedId, StringComparison.Ordinal) + && normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal)); } private static string NormalizeComparisonKey(string? value) diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotApprovalCoordinator.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotApprovalCoordinator.cs index d42b912..5e786c9 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotApprovalCoordinator.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotApprovalCoordinator.cs @@ -6,6 +6,11 @@ namespace Eryx.AgentHost.Services; internal sealed class CopilotApprovalCoordinator { + private const string ApprovedDecision = "approved"; + private const string RejectedDecision = "rejected"; + private const string ToolCallApprovalKind = "tool-call"; + private const string WebFetchToolName = "web_fetch"; + private readonly ConcurrentDictionary _pendingApprovals = new(StringComparer.Ordinal); public Task ResolveApprovalAsync( @@ -14,27 +19,13 @@ internal sealed class CopilotApprovalCoordinator { 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}\"."), - }; + string approvalId = RequireApprovalId(command.ApprovalId); + PendingApprovalRequest pending = GetPendingApproval(approvalId); + PermissionRequestResultKind decision = ParseDecision(command.Decision); if (!pending.Decision.TrySetResult(decision)) { - throw new InvalidOperationException($"Approval \"{command.ApprovalId}\" is no longer pending."); + throw new InvalidOperationException($"Approval \"{approvalId}\" is no longer pending."); } return Task.CompletedTask; @@ -49,33 +40,27 @@ internal sealed class CopilotApprovalCoordinator Func onApproval, CancellationToken cancellationToken) { - TryGetApprovalToolName(request, toolNamesByCallId, out string? toolName); - + string? toolName = ResolveApprovalToolName(request, toolNamesByCallId); if (!RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName)) { - return new PermissionRequestResult - { - Kind = PermissionRequestResultKind.Approved, - }; + return CreateApprovalResult(PermissionRequestResultKind.Approved); } - string approvalId = CreateApprovalRequestId(); - TaskCompletionSource decisionSource = - new(TaskCreationOptions.RunContinuationsAsynchronously); - PendingApprovalRequest pending = new( - command.RequestId, - command.SessionId, - approvalId, - decisionSource); - - if (!_pendingApprovals.TryAdd(approvalId, pending)) + PendingApprovalRequest pending = CreatePendingApproval(command); + if (!_pendingApprovals.TryAdd(pending.ApprovalId, pending)) { - throw new InvalidOperationException($"Approval \"{approvalId}\" is already pending."); + throw new InvalidOperationException($"Approval \"{pending.ApprovalId}\" is already pending."); } try { - await onApproval(BuildPermissionApprovalEvent(command, agent, request, invocation, approvalId, toolName)) + await onApproval(BuildPermissionApprovalEvent( + command, + agent, + request, + invocation, + pending.ApprovalId, + toolName)) .ConfigureAwait(false); using CancellationTokenRegistration registration = cancellationToken.Register( @@ -84,17 +69,14 @@ internal sealed class CopilotApprovalCoordinator ((TaskCompletionSource)state!) .TrySetCanceled(); }, - decisionSource); + pending.Decision); - PermissionRequestResultKind decision = await decisionSource.Task.ConfigureAwait(false); - return new PermissionRequestResult - { - Kind = decision, - }; + PermissionRequestResultKind decision = await pending.Decision.Task.ConfigureAwait(false); + return CreateApprovalResult(decision); } finally { - _pendingApprovals.TryRemove(approvalId, out _); + _pendingApprovals.TryRemove(pending.ApprovalId, out _); } } @@ -110,14 +92,10 @@ internal sealed class CopilotApprovalCoordinator ? "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() + string? sessionId = NormalizeOptionalString(invocation.SessionId); + string? normalizedToolName = NormalizeOptionalString(toolName); + string? requestedUrl = request is PermissionRequestUrl urlRequest + ? NormalizeOptionalString(urlRequest.Url) : null; string title = normalizedToolName is null ? $"Approve {permissionKind}" @@ -146,9 +124,9 @@ internal sealed class CopilotApprovalCoordinator 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, + ApprovalKind = ToolCallApprovalKind, + AgentId = NormalizeOptionalString(agent.Id), + AgentName = NormalizeOptionalString(agentName), ToolName = normalizedToolName, PermissionKind = permissionKind, Title = title, @@ -166,40 +144,14 @@ internal sealed class CopilotApprovalCoordinator 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) + if (!HasMatchingToolCallCheckpoint(approvalPolicy.Rules, agentId)) { return false; } - if (string.IsNullOrWhiteSpace(toolName)) - { - return true; - } - - return !approvalPolicy.AutoApprovedToolNames.Any(candidate => - string.Equals(candidate, toolName, StringComparison.OrdinalIgnoreCase)); + return string.IsNullOrWhiteSpace(toolName) + || !approvalPolicy.AutoApprovedToolNames.Any(candidate => + string.Equals(candidate, toolName, StringComparison.OrdinalIgnoreCase)); } internal static bool TryGetApprovalToolName( @@ -207,51 +159,149 @@ internal sealed class CopilotApprovalCoordinator 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); + toolName = ResolveApprovalToolName(request, toolNamesByCallId); + return toolName is not null; } internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName) => TryGetApprovalToolName(request, toolNamesByCallId: null, out toolName); + private static bool HasMatchingToolCallCheckpoint( + IReadOnlyList rules, + string agentId) + { + foreach (ApprovalCheckpointRuleDto rule in rules) + { + if (!string.Equals(rule.Kind, ToolCallApprovalKind, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (rule.AgentIds.Count == 0 + || rule.AgentIds.Any(candidate => + string.Equals(candidate, agentId, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + + return false; + } + + private static PendingApprovalRequest CreatePendingApproval(RunTurnCommandDto command) + { + return new PendingApprovalRequest( + command.RequestId, + command.SessionId, + CreateApprovalRequestId(), + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + } + + private static PermissionRequestResult CreateApprovalResult(PermissionRequestResultKind decision) + { + return new PermissionRequestResult + { + Kind = decision, + }; + } + + private static string? ResolveApprovalToolName( + PermissionRequest request, + IReadOnlyDictionary? toolNamesByCallId) + { + return GetDirectToolName(request) + ?? ResolveToolNameFromLookup(request, toolNamesByCallId) + ?? GetFallbackToolName(request); + } + + private static string? GetDirectToolName(PermissionRequest request) + { + return request switch + { + PermissionRequestMcp mcp => NormalizeOptionalString(mcp.ToolName), + PermissionRequestCustomTool customTool => NormalizeOptionalString(customTool.ToolName), + PermissionRequestHook hook => NormalizeOptionalString(hook.ToolName), + _ => null, + }; + } + + private static string? ResolveToolNameFromLookup( + PermissionRequest request, + IReadOnlyDictionary? toolNamesByCallId) + { + if (toolNamesByCallId is null) + { + return null; + } + + string? toolCallId = GetToolCallId(request); + if (toolCallId is null + || !toolNamesByCallId.TryGetValue(toolCallId, out string? resolvedToolName)) + { + return null; + } + + return NormalizeOptionalString(resolvedToolName); + } + + private static string? GetToolCallId(PermissionRequest request) + { + return request switch + { + PermissionRequestShell shell => NormalizeOptionalString(shell.ToolCallId), + PermissionRequestWrite write => NormalizeOptionalString(write.ToolCallId), + PermissionRequestRead read => NormalizeOptionalString(read.ToolCallId), + PermissionRequestMcp mcp => NormalizeOptionalString(mcp.ToolCallId), + PermissionRequestUrl url => NormalizeOptionalString(url.ToolCallId), + PermissionRequestMemory memory => NormalizeOptionalString(memory.ToolCallId), + PermissionRequestCustomTool customTool => NormalizeOptionalString(customTool.ToolCallId), + PermissionRequestHook hook => NormalizeOptionalString(hook.ToolCallId), + _ => null, + }; + } + + private static string? GetFallbackToolName(PermissionRequest request) + { + return request switch + { + PermissionRequestUrl => WebFetchToolName, + _ => null, + }; + } + + private PendingApprovalRequest GetPendingApproval(string approvalId) + { + if (_pendingApprovals.TryGetValue(approvalId, out PendingApprovalRequest? pending)) + { + return pending; + } + + throw new InvalidOperationException($"Approval \"{approvalId}\" is not pending."); + } + + private static string RequireApprovalId(string? approvalId) + { + string? normalizedApprovalId = NormalizeOptionalString(approvalId); + return normalizedApprovalId + ?? throw new InvalidOperationException("Approval ID is required."); + } + + private static PermissionRequestResultKind ParseDecision(string? decision) + { + return NormalizeOptionalString(decision)?.ToLowerInvariant() switch + { + ApprovedDecision => PermissionRequestResultKind.Approved, + RejectedDecision => PermissionRequestResultKind.DeniedInteractivelyByUser, + _ => throw new InvalidOperationException( + $"Unsupported approval decision \"{decision}\"."), + }; + } + 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(); diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotCliPathResolver.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotCliPathResolver.cs index eb92009..4d4e942 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotCliPathResolver.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotCliPathResolver.cs @@ -6,7 +6,9 @@ namespace Eryx.AgentHost.Services; internal static class CopilotCliPathResolver { private const string CopilotCommandName = "copilot"; + private const string DefaultWindowsCommandProcessor = "cmd.exe"; private const string DefaultWindowsPathExtensions = ".COM;.EXE;.BAT;.CMD"; + private static readonly string[] BlockedCliEnvironmentPrefixes = ["BUN_", "COPILOT_", "ELECTRON_", "NODE_", "NPM_"]; public static CopilotClientOptions CreateClientOptions() @@ -68,21 +70,14 @@ internal static class CopilotCliPathResolver ArgumentNullException.ThrowIfNull(environmentVariables); Dictionary sanitizedEnvironment = new(StringComparer.OrdinalIgnoreCase); - foreach (KeyValuePair entry in environmentVariables) { - if (string.IsNullOrWhiteSpace(entry.Key) || entry.Value is null) + if (ShouldSkipEnvironmentEntry(entry)) { continue; } - string normalizedKey = entry.Key.ToUpperInvariant(); - if (BlockedCliEnvironmentPrefixes.Any(prefix => normalizedKey.StartsWith(prefix, StringComparison.Ordinal))) - { - continue; - } - - sanitizedEnvironment[entry.Key] = entry.Value; + sanitizedEnvironment[entry.Key] = entry.Value!; } return sanitizedEnvironment; @@ -97,33 +92,36 @@ internal static class CopilotCliPathResolver return new CopilotCliLaunch(cliPath, []); } - string launchPath = string.IsNullOrWhiteSpace(commandProcessorPath) - ? "cmd.exe" - : commandProcessorPath; - return new CopilotCliLaunch( - launchPath, + ResolveCommandProcessorPath(commandProcessorPath), ["/d", "/s", "/c", CopilotCommandName]); } + private static bool ShouldSkipEnvironmentEntry(KeyValuePair entry) + { + if (string.IsNullOrWhiteSpace(entry.Key) || entry.Value is null) + { + return true; + } + + string normalizedKey = entry.Key.ToUpperInvariant(); + return BlockedCliEnvironmentPrefixes.Any(prefix => normalizedKey.StartsWith(prefix, StringComparison.Ordinal)); + } + + private static string ResolveCommandProcessorPath(string? commandProcessorPath) + { + return string.IsNullOrWhiteSpace(commandProcessorPath) + ? DefaultWindowsCommandProcessor + : commandProcessorPath; + } + private static string? ResolveCliPath( string? pathValue, string? pathExtValue, bool isWindows, Func fileExists) { - if (string.IsNullOrWhiteSpace(pathValue)) - { - return null; - } - - StringComparer comparer = isWindows ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; - - foreach (string directory in pathValue - .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(segment => segment.Trim('"')) - .Where(segment => !string.IsNullOrWhiteSpace(segment)) - .Distinct(comparer)) + foreach (string directory in EnumerateDistinctSearchDirectories(pathValue, isWindows)) { foreach (string candidateName in GetCandidateFileNames(pathExtValue, isWindows)) { @@ -138,6 +136,24 @@ internal static class CopilotCliPathResolver return null; } + private static IEnumerable EnumerateDistinctSearchDirectories(string? pathValue, bool isWindows) + { + if (string.IsNullOrWhiteSpace(pathValue)) + { + yield break; + } + + StringComparer comparer = isWindows ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + foreach (string directory in pathValue + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(segment => segment.Trim('"')) + .Where(segment => !string.IsNullOrWhiteSpace(segment)) + .Distinct(comparer)) + { + yield return directory; + } + } + private static IEnumerable GetCandidateFileNames(string? pathExtValue, bool isWindows) { yield return CopilotCommandName; diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotTurnExecutionState.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotTurnExecutionState.cs index cb5724e..0e2698f 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotTurnExecutionState.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotTurnExecutionState.cs @@ -89,7 +89,7 @@ internal sealed class CopilotTurnExecutionState return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}"; } - public (string MessageId, string AuthorName, string Content) AppendDelta( + public TranscriptSegment AppendDelta( string messageId, string authorName, string delta) @@ -117,7 +117,7 @@ internal sealed class CopilotTurnExecutionState IReadOnlyList inputMessages) { List newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(allMessages, inputMessages); - CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessages( + CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments( _command, newMessages, _transcriptBuffer.Snapshot(), @@ -128,7 +128,7 @@ internal sealed class CopilotTurnExecutionState { if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0) { - CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessages( + CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments( _command, [], _transcriptBuffer.Snapshot(), diff --git a/sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs b/sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs index cb185bc..3576faa 100644 --- a/sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs +++ b/sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs @@ -6,6 +6,9 @@ namespace Eryx.AgentHost.Services; internal sealed class SessionToolingBundle : IAsyncDisposable { + private const string LocalTransport = "local"; + private const string WildcardToolName = "*"; + private readonly List _disposables = []; private SessionToolingBundle( @@ -26,16 +29,8 @@ internal sealed class SessionToolingBundle : IAsyncDisposable CancellationToken cancellationToken) { Dictionary mcpServers = BuildMcpServerConfigurations(tooling?.McpServers ?? []); - List disposables = []; - List tools = []; - - foreach (RunTurnLspProfileConfigDto profile in tooling?.LspProfiles ?? []) - { - LspToolSession lspSession = await LspToolSession.StartAsync(profile, projectPath, cancellationToken) - .ConfigureAwait(false); - disposables.Add(lspSession); - tools.AddRange(lspSession.Tools); - } + (List tools, List disposables) = + await BuildLspToolingAsync(tooling?.LspProfiles ?? [], projectPath, cancellationToken).ConfigureAwait(false); SessionToolingBundle bundle = new(mcpServers, tools); bundle._disposables.AddRange(disposables); @@ -57,42 +52,81 @@ internal sealed class SessionToolingBundle : IAsyncDisposable foreach (RunTurnMcpServerConfigDto server in servers) { - string serverName = string.IsNullOrWhiteSpace(server.Name) ? server.Id : server.Name.Trim(); - List tools = server.Tools.Count == 0 ? ["*"] : server.Tools.ToList(); - - if (string.Equals(server.Transport, "local", StringComparison.OrdinalIgnoreCase)) - { - if (string.IsNullOrWhiteSpace(server.Command)) - { - throw new InvalidOperationException($"MCP server \"{serverName}\" is missing a command."); - } - - configurations[serverName] = new McpLocalServerConfig - { - Type = "local", - Timeout = server.TimeoutMs, - Command = server.Command, - Args = server.Args?.ToList() ?? [], - Cwd = string.IsNullOrWhiteSpace(server.Cwd) ? null : server.Cwd, - Tools = tools, - }; - continue; - } - - if (string.IsNullOrWhiteSpace(server.Url)) - { - throw new InvalidOperationException($"MCP server \"{serverName}\" is missing a URL."); - } - - configurations[serverName] = new McpRemoteServerConfig - { - Type = server.Transport, - Timeout = server.TimeoutMs, - Url = server.Url, - Tools = tools, - }; + configurations[ResolveServerName(server)] = CreateServerConfiguration(server); } return configurations; } + + private static async Task<(List Tools, List Disposables)> BuildLspToolingAsync( + IReadOnlyList profiles, + string projectPath, + CancellationToken cancellationToken) + { + List tools = []; + List disposables = []; + + foreach (RunTurnLspProfileConfigDto profile in profiles) + { + LspToolSession session = await LspToolSession.StartAsync(profile, projectPath, cancellationToken) + .ConfigureAwait(false); + disposables.Add(session); + tools.AddRange(session.Tools); + } + + return (tools, disposables); + } + + private static object CreateServerConfiguration(RunTurnMcpServerConfigDto server) + { + return string.Equals(server.Transport, LocalTransport, StringComparison.OrdinalIgnoreCase) + ? CreateLocalServerConfiguration(server) + : CreateRemoteServerConfiguration(server); + } + + private static McpLocalServerConfig CreateLocalServerConfiguration(RunTurnMcpServerConfigDto server) + { + string serverName = ResolveServerName(server); + if (string.IsNullOrWhiteSpace(server.Command)) + { + throw new InvalidOperationException($"MCP server \"{serverName}\" is missing a command."); + } + + return new McpLocalServerConfig + { + Type = LocalTransport, + Timeout = server.TimeoutMs, + Command = server.Command, + Args = server.Args?.ToList() ?? [], + Cwd = string.IsNullOrWhiteSpace(server.Cwd) ? null : server.Cwd, + Tools = ResolveTools(server), + }; + } + + private static McpRemoteServerConfig CreateRemoteServerConfiguration(RunTurnMcpServerConfigDto server) + { + string serverName = ResolveServerName(server); + if (string.IsNullOrWhiteSpace(server.Url)) + { + throw new InvalidOperationException($"MCP server \"{serverName}\" is missing a URL."); + } + + return new McpRemoteServerConfig + { + Type = server.Transport, + Timeout = server.TimeoutMs, + Url = server.Url, + Tools = ResolveTools(server), + }; + } + + private static string ResolveServerName(RunTurnMcpServerConfigDto server) + { + return string.IsNullOrWhiteSpace(server.Name) ? server.Id : server.Name.Trim(); + } + + private static List ResolveTools(RunTurnMcpServerConfigDto server) + { + return server.Tools.Count == 0 ? [WildcardToolName] : server.Tools.ToList(); + } } diff --git a/sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs b/sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs index 991e06e..713b2bb 100644 --- a/sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs +++ b/sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs @@ -9,6 +9,11 @@ namespace Eryx.AgentHost.Services; public sealed class SidecarProtocolHost { + private const string DescribeCapabilitiesCommandType = "describe-capabilities"; + private const string ValidatePatternCommandType = "validate-pattern"; + private const string RunTurnCommandType = "run-turn"; + private const string ResolveApprovalCommandType = "resolve-approval"; + private static readonly string[] AuthenticationErrorIndicators = [ "login", @@ -26,6 +31,7 @@ public sealed class SidecarProtocolHost private readonly PatternValidator _patternValidator; private readonly ITurnWorkflowRunner _workflowRunner; private readonly JsonSerializerOptions _jsonOptions; + private readonly IReadOnlyDictionary> _commandHandlers; private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly ConcurrentDictionary _inFlight = new(StringComparer.Ordinal); @@ -47,6 +53,13 @@ public sealed class SidecarProtocolHost DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNameCaseInsensitive = true, }; + _commandHandlers = new Dictionary>(StringComparer.Ordinal) + { + [DescribeCapabilitiesCommandType] = HandleDescribeCapabilitiesAsync, + [ValidatePatternCommandType] = HandleValidatePatternAsync, + [RunTurnCommandType] = HandleRunTurnAsync, + [ResolveApprovalCommandType] = HandleResolveApprovalAsync, + }; } public async Task RunAsync(TextReader input, TextWriter output, CancellationToken cancellationToken) @@ -65,17 +78,9 @@ public sealed class SidecarProtocolHost } SidecarCommandEnvelope envelope = DeserializeEnvelope(line); - Task task = HandleCommandAsync(line, envelope, output, cancellationToken); - _inFlight[envelope.RequestId] = task; - _ = task.ContinueWith( - _ => - { - _inFlight.TryRemove(envelope.RequestId, out Task? removedTask); - return removedTask is not null; - }, - CancellationToken.None, - TaskContinuationOptions.None, - TaskScheduler.Default); + TrackInFlightRequest( + envelope.RequestId, + HandleCommandAsync(line, envelope, output, cancellationToken)); } await Task.WhenAll(_inFlight.Values).ConfigureAwait(false); @@ -87,89 +92,124 @@ public sealed class SidecarProtocolHost ?? throw new InvalidOperationException("Could not deserialize sidecar command envelope."); } + private void TrackInFlightRequest(string requestId, Task task) + { + _inFlight[requestId] = task; + _ = task.ContinueWith( + _ => + { + _inFlight.TryRemove(requestId, out Task? removedTask); + return removedTask is not null; + }, + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default); + } + private async Task HandleCommandAsync( string rawCommand, SidecarCommandEnvelope envelope, TextWriter output, CancellationToken cancellationToken) { + CommandContext context = new(rawCommand, envelope, output, cancellationToken); + try { - switch (envelope.Type) - { - case "describe-capabilities": - await WriteAsync(output, new CapabilitiesEventDto - { - Type = "capabilities", - RequestId = envelope.RequestId, - Capabilities = await _capabilitiesProvider(cancellationToken).ConfigureAwait(false), - }, cancellationToken).ConfigureAwait(false); - break; - - case "validate-pattern": - ValidatePatternCommandDto validateCommand = - JsonSerializer.Deserialize(rawCommand, _jsonOptions) - ?? throw new InvalidOperationException("Could not deserialize validate-pattern command."); - - await WriteAsync(output, new PatternValidationEventDto - { - Type = "pattern-validation", - RequestId = envelope.RequestId, - Issues = _patternValidator.Validate(validateCommand.Pattern), - }, cancellationToken).ConfigureAwait(false); - break; - - case "run-turn": - RunTurnCommandDto runTurnCommand = - JsonSerializer.Deserialize(rawCommand, _jsonOptions) - ?? throw new InvalidOperationException("Could not deserialize run-turn command."); - - IReadOnlyList messages = await _workflowRunner.RunTurnAsync( - runTurnCommand, - delta => WriteAsync(output, delta, cancellationToken), - activity => WriteAsync(output, activity, cancellationToken), - approval => WriteAsync(output, approval, cancellationToken), - cancellationToken).ConfigureAwait(false); - - await WriteAsync(output, new TurnCompleteEventDto - { - Type = "turn-complete", - RequestId = envelope.RequestId, - SessionId = runTurnCommand.SessionId, - Messages = messages, - }, cancellationToken).ConfigureAwait(false); - break; - - case "resolve-approval": - ResolveApprovalCommandDto resolveApprovalCommand = - JsonSerializer.Deserialize(rawCommand, _jsonOptions) - ?? throw new InvalidOperationException("Could not deserialize resolve-approval command."); - - await _workflowRunner.ResolveApprovalAsync(resolveApprovalCommand, cancellationToken) - .ConfigureAwait(false); - break; - - default: - throw new NotSupportedException($"Unknown sidecar command type '{envelope.Type}'."); - } - - await WriteAsync(output, new CommandCompleteEventDto - { - Type = "command-complete", - RequestId = envelope.RequestId, - }, cancellationToken).ConfigureAwait(false); + await ExecuteCommandAsync(context).ConfigureAwait(false); + await WriteCommandCompleteAsync(context).ConfigureAwait(false); } catch (Exception ex) { - await WriteAsync(output, new CommandErrorEventDto - { - Type = "command-error", - RequestId = envelope.RequestId, - Message = ex.Message, - }, cancellationToken).ConfigureAwait(false); + await WriteCommandErrorAsync(context, ex.Message).ConfigureAwait(false); } } + private Task ExecuteCommandAsync(CommandContext context) + { + if (_commandHandlers.TryGetValue(context.Envelope.Type, out Func? handler)) + { + return handler(context); + } + + throw new NotSupportedException($"Unknown sidecar command type '{context.Envelope.Type}'."); + } + + private async Task HandleDescribeCapabilitiesAsync(CommandContext context) + { + await WriteAsync(context.Output, new CapabilitiesEventDto + { + Type = "capabilities", + RequestId = context.Envelope.RequestId, + Capabilities = await _capabilitiesProvider(context.CancellationToken).ConfigureAwait(false), + }, context.CancellationToken).ConfigureAwait(false); + } + + private async Task HandleValidatePatternAsync(CommandContext context) + { + ValidatePatternCommandDto command = DeserializeCommand(context); + + await WriteAsync(context.Output, new PatternValidationEventDto + { + Type = "pattern-validation", + RequestId = context.Envelope.RequestId, + Issues = _patternValidator.Validate(command.Pattern), + }, context.CancellationToken).ConfigureAwait(false); + } + + private async Task HandleRunTurnAsync(CommandContext context) + { + RunTurnCommandDto command = DeserializeCommand(context); + IReadOnlyList messages = await _workflowRunner.RunTurnAsync( + command, + delta => WriteAsync(context.Output, delta, context.CancellationToken), + activity => WriteAsync(context.Output, activity, context.CancellationToken), + approval => WriteAsync(context.Output, approval, context.CancellationToken), + context.CancellationToken) + .ConfigureAwait(false); + + await WriteAsync(context.Output, new TurnCompleteEventDto + { + Type = "turn-complete", + RequestId = context.Envelope.RequestId, + SessionId = command.SessionId, + Messages = messages, + }, context.CancellationToken).ConfigureAwait(false); + } + + private async Task HandleResolveApprovalAsync(CommandContext context) + { + ResolveApprovalCommandDto command = DeserializeCommand(context); + await _workflowRunner.ResolveApprovalAsync(command, context.CancellationToken).ConfigureAwait(false); + } + + private TCommand DeserializeCommand(CommandContext context) + where TCommand : SidecarCommandEnvelope + { + return JsonSerializer.Deserialize(context.RawCommand, _jsonOptions) + ?? throw new InvalidOperationException( + $"Could not deserialize {context.Envelope.Type} command."); + } + + private Task WriteCommandCompleteAsync(CommandContext context) + { + return WriteAsync(context.Output, new CommandCompleteEventDto + { + Type = "command-complete", + RequestId = context.Envelope.RequestId, + }, context.CancellationToken); + } + + private Task WriteCommandErrorAsync(CommandContext context, string message) + { + return WriteAsync(context.Output, new CommandErrorEventDto + { + Type = "command-error", + RequestId = context.Envelope.RequestId, + Message = message, + }, context.CancellationToken); + } + private async Task WriteAsync(TextWriter output, object payload, CancellationToken cancellationToken) { string json = JsonSerializer.Serialize(payload, _jsonOptions); @@ -187,30 +227,28 @@ public sealed class SidecarProtocolHost private static async Task BuildCapabilitiesAsync(CancellationToken cancellationToken) { - IReadOnlyList models = []; - IReadOnlyList runtimeTools = []; - CopilotCliContext cliContext; - SidecarConnectionDiagnosticsDto connection; - SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null; - SidecarCopilotAccountDiagnosticsDto? account = null; - try { - cliContext = CopilotCliPathResolver.ResolveCliContext(); + CopilotCliContext cliContext = CopilotCliPathResolver.ResolveCliContext(); + CapabilityProbeResult probe = await ProbeCapabilitiesAsync(cliContext, cancellationToken).ConfigureAwait(false); + return CreateCapabilities(probe.Models, probe.RuntimeTools, probe.Connection); } catch (Exception exception) { - connection = CreateMissingCliDiagnostics(exception); + SidecarConnectionDiagnosticsDto connection = CreateMissingCliDiagnostics(exception); Console.Error.WriteLine($"[eryx sidecar] {connection.Summary} {exception.Message}"); - - return new SidecarCapabilitiesDto - { - Modes = BuildModeCapabilities(), - Models = models, - Connection = connection, - }; + return CreateCapabilities([], [], connection); } + } + private static async Task ProbeCapabilitiesAsync( + CopilotCliContext cliContext, + CancellationToken cancellationToken) + { + IReadOnlyList models = []; + IReadOnlyList runtimeTools = []; + SidecarCopilotAccountDiagnosticsDto? account = null; + SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null; Task cliVersionTask = CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken); @@ -224,29 +262,37 @@ public sealed class SidecarProtocolHost GetAuthStatusResponse? authStatus = await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false); account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync( - authStatus, - cliContext.Environment, - cancellationToken).ConfigureAwait(false); + authStatus, + cliContext.Environment, + cancellationToken) + .ConfigureAwait(false); models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false); - try - { - runtimeTools = await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false); - } - catch (Exception exception) - { - Console.Error.WriteLine($"[eryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}"); - } + runtimeTools = await TryListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false); cliVersion = await cliVersionTask.ConfigureAwait(false); - connection = CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account); + + return new CapabilityProbeResult( + models, + runtimeTools, + CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account)); } catch (Exception exception) { cliVersion = await cliVersionTask.ConfigureAwait(false); - connection = CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account); Console.Error.WriteLine($"[eryx sidecar] Failed to list available Copilot models: {exception.Message}"); - } + return new CapabilityProbeResult( + models, + runtimeTools, + CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account)); + } + } + + private static SidecarCapabilitiesDto CreateCapabilities( + IReadOnlyList models, + IReadOnlyList runtimeTools, + SidecarConnectionDiagnosticsDto connection) + { return new SidecarCapabilitiesDto { Modes = BuildModeCapabilities(), @@ -295,6 +341,21 @@ public sealed class SidecarProtocolHost .ToList(); } + private static async Task> TryListAvailableRuntimeToolsAsync( + CopilotClient client, + CancellationToken cancellationToken) + { + try + { + return await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[eryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}"); + return []; + } + } + private static async Task> ListAvailableRuntimeToolsAsync( CopilotClient client, CancellationToken cancellationToken) @@ -388,4 +449,15 @@ public sealed class SidecarProtocolHost return "copilot-error"; } + + private sealed record CommandContext( + string RawCommand, + SidecarCommandEnvelope Envelope, + TextWriter Output, + CancellationToken CancellationToken); + + private sealed record CapabilityProbeResult( + IReadOnlyList Models, + IReadOnlyList RuntimeTools, + SidecarConnectionDiagnosticsDto Connection); } diff --git a/sidecar/src/Eryx.AgentHost/Services/StreamingTextMerger.cs b/sidecar/src/Eryx.AgentHost/Services/StreamingTextMerger.cs index e10b757..f16889a 100644 --- a/sidecar/src/Eryx.AgentHost/Services/StreamingTextMerger.cs +++ b/sidecar/src/Eryx.AgentHost/Services/StreamingTextMerger.cs @@ -4,6 +4,11 @@ namespace Eryx.AgentHost.Services; internal static partial class StreamingTextMerger { + private const double SnapshotReplacementMinLengthRatio = 0.6; + private const int SnapshotReplacementMinTokenCount = 3; + private const double SnapshotReplacementSharedTokenRatio = 0.5; + private const string CharactersThatDoNotNeedLeadingSpace = "([{/\"'`"; + public static string Merge(string current, string incoming) { if (string.IsNullOrEmpty(current)) @@ -16,21 +21,10 @@ internal static partial class StreamingTextMerger return current; } - if (incoming.StartsWith(current, StringComparison.Ordinal) - || incoming.Contains(current, StringComparison.Ordinal)) + if (TryMergeSnapshotVariants(current, incoming, out string merged) + || TryMergeByOverlap(current, incoming, out merged)) { - return incoming; - } - - if (current.Contains(incoming, StringComparison.Ordinal)) - { - return current; - } - - int overlap = ComputeSuffixPrefixOverlap(current, incoming); - if (overlap > 0) - { - return current + incoming[overlap..]; + return merged; } if (ShouldReplaceWithSnapshot(current, incoming)) @@ -38,22 +32,51 @@ internal static partial class StreamingTextMerger return incoming; } - return AppendWithNaturalBoundary(current, incoming); + return current + ResolveBoundarySeparator(current, incoming) + incoming; } - private static string AppendWithNaturalBoundary(string current, string incoming) + private static bool TryMergeSnapshotVariants(string current, string incoming, out string merged) + { + if (incoming.StartsWith(current, StringComparison.Ordinal) + || incoming.Contains(current, StringComparison.Ordinal)) + { + merged = incoming; + return true; + } + + if (current.Contains(incoming, StringComparison.Ordinal)) + { + merged = current; + return true; + } + + merged = string.Empty; + return false; + } + + private static bool TryMergeByOverlap(string current, string incoming, out string merged) + { + int overlapLength = ComputeSuffixPrefixOverlap(current, incoming); + if (overlapLength == 0) + { + merged = string.Empty; + return false; + } + + merged = current + incoming[overlapLength..]; + return true; + } + + private static string ResolveBoundarySeparator(string current, string incoming) { if (ShouldInsertNewlineBoundary(current, incoming)) { - return current + "\n" + incoming; + return "\n"; } - if (ShouldInsertSpaceBoundary(current, incoming)) - { - return current + " " + incoming; - } - - return current + incoming; + return ShouldInsertSpaceBoundary(current, incoming) + ? " " + : string.Empty; } private static int ComputeSuffixPrefixOverlap(string current, string incoming) @@ -72,40 +95,48 @@ internal static partial class StreamingTextMerger private static bool ShouldReplaceWithSnapshot(string current, string incoming) { - if (incoming.Length < Math.Floor(current.Length * 0.6)) + if (!HasViableSnapshotLength(current, incoming)) { return false; } HashSet currentTokens = Tokenize(current).ToHashSet(StringComparer.Ordinal); HashSet incomingTokens = Tokenize(incoming).ToHashSet(StringComparer.Ordinal); - if (currentTokens.Count < 3 || incomingTokens.Count < 3) + if (!HasEnoughTokensForSnapshotComparison(currentTokens, incomingTokens)) { return false; } - int shared = incomingTokens.Count(token => currentTokens.Contains(token)); - return shared / (double)Math.Min(currentTokens.Count, incomingTokens.Count) >= 0.5; + int sharedTokenCount = incomingTokens.Count(token => currentTokens.Contains(token)); + double sharedTokenRatio = sharedTokenCount / (double)Math.Min(currentTokens.Count, incomingTokens.Count); + return sharedTokenRatio >= SnapshotReplacementSharedTokenRatio; + } + + private static bool HasViableSnapshotLength(string current, string incoming) + { + return incoming.Length >= Math.Floor(current.Length * SnapshotReplacementMinLengthRatio); + } + + private static bool HasEnoughTokensForSnapshotComparison( + HashSet currentTokens, + HashSet incomingTokens) + { + return currentTokens.Count >= SnapshotReplacementMinTokenCount + && incomingTokens.Count >= SnapshotReplacementMinTokenCount; } private static bool ShouldInsertNewlineBoundary(string current, string incoming) { - if (current.EndsWith('\n')) - { - return false; - } - - return MarkdownBlockPrefixRegex().IsMatch(incoming.TrimStart()); + return !current.EndsWith('\n') + && MarkdownBlockPrefixRegex().IsMatch(incoming.TrimStart()); } private static bool ShouldInsertSpaceBoundary(string current, string incoming) { - char lastChar = current[^1]; - char firstChar = incoming[0]; - - if (char.IsWhiteSpace(lastChar) - || char.IsWhiteSpace(firstChar) - || "([{/\"'`".Contains(lastChar)) + char lastCharacter = current[^1]; + char firstCharacter = incoming[0]; + if (HasExistingBoundary(lastCharacter, firstCharacter) + || CharactersThatDoNotNeedLeadingSpace.Contains(lastCharacter)) { return false; } @@ -115,13 +146,24 @@ internal static partial class StreamingTextMerger return false; } - if (MarkdownInlinePrefixRegex().IsMatch(incoming) - || char.IsUpper(firstChar) - || char.IsDigit(firstChar)) - { - return true; - } + return StartsLikeASeparatedInlineFragment(firstCharacter, incoming) + || LooksLikeWordBoundary(current, incoming); + } + private static bool HasExistingBoundary(char lastCharacter, char firstCharacter) + { + return char.IsWhiteSpace(lastCharacter) || char.IsWhiteSpace(firstCharacter); + } + + private static bool StartsLikeASeparatedInlineFragment(char firstCharacter, string incoming) + { + return MarkdownInlinePrefixRegex().IsMatch(incoming) + || char.IsUpper(firstCharacter) + || char.IsDigit(firstCharacter); + } + + private static bool LooksLikeWordBoundary(string current, string incoming) + { string[] currentTokens = Tokenize(current).ToArray(); string[] incomingTokens = Tokenize(incoming).ToArray(); string firstIncomingToken = incomingTokens.FirstOrDefault() ?? string.Empty; diff --git a/sidecar/src/Eryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs b/sidecar/src/Eryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs index a7804e1..c657e30 100644 --- a/sidecar/src/Eryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs +++ b/sidecar/src/Eryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs @@ -8,47 +8,25 @@ namespace Eryx.AgentHost.Services; internal static class WorkflowRequestInfoInterpreter { + private const string HandoffActivityType = "handoff"; + private const string ToolCallingActivityType = "tool-calling"; + private const string CodeInterpreterToolName = "code interpreter"; + private const string ImageGenerationToolName = "image generation"; + public static AgentActivityEventDto? TryCreateActivityFromRequest( RunTurnCommandDto command, RequestInfoEvent requestInfo, AgentIdentity? activeAgent, ConcurrentDictionary toolNamesByCallId) { - if (TryGetHandoffTarget(command.Pattern, requestInfo, out AgentIdentity handoffAgent)) + RequestInterpretation interpretation = InterpretRequest(command.Pattern, requestInfo); + return interpretation switch { - 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, + HandoffRequestInterpretation handoff => + CreateHandoffActivity(command, handoff.TargetAgent, activeAgent), + ToolRequestInterpretation tool when activeAgent.HasValue => + CreateToolCallingActivity(command, activeAgent.Value, tool, toolNamesByCallId), + _ => null, }; } @@ -56,17 +34,71 @@ internal static class WorkflowRequestInfoInterpreter RunTurnCommandDto command, RequestInfoEvent requestInfo) { - if (!string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase)) + return string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase) + && InterpretRequest(command.Pattern, requestInfo) is UnknownRequestInterpretation; + } + + private static AgentActivityEventDto CreateHandoffActivity( + RunTurnCommandDto command, + AgentIdentity handoffAgent, + AgentIdentity? activeAgent) + { + return new AgentActivityEventDto { - return false; + Type = "agent-activity", + RequestId = command.RequestId, + SessionId = command.SessionId, + ActivityType = HandoffActivityType, + AgentId = handoffAgent.AgentId, + AgentName = handoffAgent.AgentName, + SourceAgentId = activeAgent?.AgentId, + SourceAgentName = activeAgent?.AgentName, + }; + } + + private static AgentActivityEventDto CreateToolCallingActivity( + RunTurnCommandDto command, + AgentIdentity activeAgent, + ToolRequestInterpretation tool, + ConcurrentDictionary toolNamesByCallId) + { + TrackToolCallId(toolNamesByCallId, tool.ToolCallId, tool.ToolName); + + return new AgentActivityEventDto + { + Type = "agent-activity", + RequestId = command.RequestId, + SessionId = command.SessionId, + ActivityType = ToolCallingActivityType, + AgentId = activeAgent.AgentId, + AgentName = activeAgent.AgentName, + ToolName = tool.ToolName, + }; + } + + private static void TrackToolCallId( + ConcurrentDictionary toolNamesByCallId, + string? toolCallId, + string toolName) + { + if (toolCallId is not null) + { + toolNamesByCallId[toolCallId] = toolName; + } + } + + private static RequestInterpretation InterpretRequest( + PatternDefinitionDto pattern, + RequestInfoEvent requestInfo) + { + if (TryGetHandoffTarget(pattern, requestInfo, out AgentIdentity handoffAgent)) + { + return new HandoffRequestInterpretation(handoffAgent); } - if (TryGetHandoffTarget(command.Pattern, requestInfo, out _)) - { - return false; - } - - return !TryGetToolRequestInfo(requestInfo, out _, out _); + return TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId) + ? new ToolRequestInterpretation(toolName, toolCallId) + : new UnknownRequestInterpretation(); } private static bool TryGetHandoffTarget( @@ -75,6 +107,7 @@ internal static class WorkflowRequestInfoInterpreter out AgentIdentity agent) { agent = default; + object? handoffValue = requestInfo.Request.Data.As(); if (handoffValue is null) { @@ -99,12 +132,8 @@ internal static class WorkflowRequestInfoInterpreter out string toolName, out string? toolCallId) { - if (TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId)) - { - return true; - } - - return TryGetEvaluationToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId); + return TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId) + || TryGetEvaluationToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId); } private static bool TryGetStableToolRequestInfo( @@ -135,19 +164,19 @@ internal static class WorkflowRequestInfoInterpreter ?? NormalizeOptionalString(mcpToolCall.ServerName) ?? string.Empty; toolCallId = NormalizeOptionalString(mcpToolCall.CallId); - return !string.IsNullOrWhiteSpace(toolName); + return toolName.Length > 0; } if (requestData.Is(out CodeInterpreterToolCallContent? codeInterpreterToolCall)) { - toolName = "code interpreter"; + toolName = CodeInterpreterToolName; toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId); return true; } if (requestData.Is()) { - toolName = "image generation"; + toolName = ImageGenerationToolName; toolCallId = null; return true; } @@ -167,6 +196,14 @@ internal static class WorkflowRequestInfoInterpreter string json = JsonSerializer.Serialize(handoffValue, handoffValue.GetType()); return JsonSerializer.Deserialize(json); } + + private abstract record RequestInterpretation; + + private sealed record HandoffRequestInterpretation(AgentIdentity TargetAgent) : RequestInterpretation; + + private sealed record ToolRequestInterpretation(string ToolName, string? ToolCallId) : RequestInterpretation; + + private sealed record UnknownRequestInterpretation : RequestInterpretation; } internal sealed class WorkflowRequestHandoffPayload diff --git a/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs b/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs index 1504f71..16ea67d 100644 --- a/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs +++ b/sidecar/src/Eryx.AgentHost/Services/WorkflowTranscriptProjector.cs @@ -4,6 +4,12 @@ using Microsoft.Extensions.AI; namespace Eryx.AgentHost.Services; +internal readonly record struct TranscriptSegment(string MessageId, string AuthorName, string Content) +{ + public static TranscriptSegment FromTuple((string MessageId, string AuthorName, string Content) segment) + => new(segment.MessageId, segment.AuthorName, segment.Content); +} + internal static class WorkflowTranscriptProjector { public static ChatMessage ToChatMessage(ChatMessageDto message) @@ -29,68 +35,107 @@ internal static class WorkflowTranscriptProjector IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments, AgentIdentity? fallbackAgent = null) { - List mapped = []; + return ProjectCompletedMessagesFromSegments( + command, + newMessages, + segments.Select(TranscriptSegment.FromTuple).ToList(), + fallbackAgent); + } + + internal static List ProjectCompletedMessagesFromSegments( + RunTurnCommandDto command, + IReadOnlyList newMessages, + IReadOnlyList segments, + AgentIdentity? fallbackAgent = null) + { + List projectedMessages = []; int fallbackOutputIndex = 0; string createdAt = DateTimeOffset.UtcNow.ToString("O"); - List<(string MessageId, string AuthorName, string Content)> preparedSegments = - PrepareSegmentsForProjection(command.Pattern, segments); - List<(string MessageId, string AuthorName, string Content)> remainingSegments = preparedSegments.ToList(); + List preparedSegments = PrepareSegmentsForProjection(command.Pattern, segments); + List remainingSegments = preparedSegments.ToList(); List assistantMessages = newMessages.Where(message => message.Role != ChatRole.User).ToList(); for (int messageIndex = 0; messageIndex < assistantMessages.Count; messageIndex++) { ChatMessage message = assistantMessages[messageIndex]; - (string MessageId, string AuthorName, string Content)? segment = TryMatchSegment( + TranscriptSegment? matchedSegment = TryMatchSegment( message, remainingSegments, assistantMessages.Count - messageIndex, command.Pattern, fallbackAgent); - string content = message.Text ?? segment?.Content ?? string.Empty; + string content = message.Text ?? matchedSegment?.Content ?? string.Empty; if (string.IsNullOrWhiteSpace(content)) { continue; } - if (segment.HasValue) + if (matchedSegment.HasValue) { - remainingSegments.Remove(segment.Value); + remainingSegments.Remove(matchedSegment.Value); } 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, - }); + projectedMessages.Add(CreateProjectedMessage( + command, + message, + matchedSegment, + fallbackAgent, + createdAt, + fallbackOutputIndex, + content)); } - if (mapped.Count == 0 && preparedSegments.Count > 0) + if (projectedMessages.Count == 0 && preparedSegments.Count > 0) { - mapped.AddRange(preparedSegments.Select(segment => new ChatMessageDto - { - Id = segment.MessageId, - Role = "assistant", - AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Pattern, segment.AuthorName), - Content = segment.Content, - CreatedAt = createdAt, - })); + projectedMessages.AddRange(preparedSegments.Select(segment => + CreateProjectedMessageFromSegment(command, segment, createdAt))); } - return mapped; + return projectedMessages; } - private static List<(string MessageId, string AuthorName, string Content)> PrepareSegmentsForProjection( + private static ChatMessageDto CreateProjectedMessage( + RunTurnCommandDto command, + ChatMessage message, + TranscriptSegment? matchedSegment, + AgentIdentity? fallbackAgent, + string createdAt, + int fallbackOutputIndex, + string content) + { + return new ChatMessageDto + { + Id = matchedSegment?.MessageId ?? $"{command.RequestId}-final-{fallbackOutputIndex}", + Role = message.Role == ChatRole.System ? "system" : "assistant", + AuthorName = ResolveProjectedAuthorName( + command.Pattern, + message.AuthorName, + matchedSegment?.AuthorName, + fallbackAgent), + Content = content, + CreatedAt = createdAt, + }; + } + + private static ChatMessageDto CreateProjectedMessageFromSegment( + RunTurnCommandDto command, + TranscriptSegment segment, + string createdAt) + { + return new ChatMessageDto + { + Id = segment.MessageId, + Role = "assistant", + AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Pattern, segment.AuthorName), + Content = segment.Content, + CreatedAt = createdAt, + }; + } + + private static List PrepareSegmentsForProjection( PatternDefinitionDto pattern, - IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments) + IReadOnlyList segments) { if (!string.Equals(pattern.Mode, "concurrent", StringComparison.Ordinal) || segments.Count <= 1) @@ -101,12 +146,12 @@ internal static class WorkflowTranscriptProjector // Agent Framework concurrent workflows aggregate the last message emitted by each agent. // Collapse streamed segments to the most recent segment per author, preserving the order // in which those authors most recently completed so positional fallback stays aligned. - Dictionary latestSegmentByAuthor = + Dictionary latestSegmentByAuthor = new(StringComparer.Ordinal); for (int index = 0; index < segments.Count; index++) { - (string MessageId, string AuthorName, string Content) segment = segments[index]; + TranscriptSegment segment = segments[index]; string authorKey = AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName); latestSegmentByAuthor[authorKey] = (segment, index); } @@ -117,9 +162,9 @@ internal static class WorkflowTranscriptProjector .ToList(); } - private static (string MessageId, string AuthorName, string Content)? TryMatchSegment( + private static TranscriptSegment? TryMatchSegment( ChatMessage message, - IReadOnlyList<(string MessageId, string AuthorName, string Content)> remainingSegments, + IReadOnlyList remainingSegments, int remainingMessageCount, PatternDefinitionDto pattern, AgentIdentity? fallbackAgent) @@ -145,7 +190,7 @@ internal static class WorkflowTranscriptProjector AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName), resolvedAuthorName, StringComparison.Ordinal), - out (string MessageId, string AuthorName, string Content) authorMatchedSegment)) + out TranscriptSegment authorMatchedSegment)) { return authorMatchedSegment; } @@ -153,7 +198,7 @@ internal static class WorkflowTranscriptProjector if (TryFindSegment( remainingSegments, segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal), - out (string MessageId, string AuthorName, string Content) contentMatchedSegment)) + out TranscriptSegment contentMatchedSegment)) { return contentMatchedSegment; } @@ -169,7 +214,7 @@ internal static class WorkflowTranscriptProjector AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName), fallbackAgent.Value.AgentName, StringComparison.Ordinal), - out (string MessageId, string AuthorName, string Content) fallbackMatchedSegment)) + out TranscriptSegment fallbackMatchedSegment)) { return fallbackMatchedSegment; } @@ -183,11 +228,11 @@ internal static class WorkflowTranscriptProjector } private static bool TryFindSegment( - IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments, - Func<(string MessageId, string AuthorName, string Content), bool> predicate, - out (string MessageId, string AuthorName, string Content) matchedSegment) + IReadOnlyList segments, + Func predicate, + out TranscriptSegment matchedSegment) { - foreach ((string MessageId, string AuthorName, string Content) segment in segments) + foreach (TranscriptSegment segment in segments) { if (predicate(segment)) { @@ -201,13 +246,13 @@ internal static class WorkflowTranscriptProjector } private static bool TryFindLastSegment( - IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments, - Func<(string MessageId, string AuthorName, string Content), bool> predicate, - out (string MessageId, string AuthorName, string Content) matchedSegment) + IReadOnlyList segments, + Func predicate, + out TranscriptSegment matchedSegment) { for (int index = segments.Count - 1; index >= 0; index--) { - (string MessageId, string AuthorName, string Content) segment = segments[index]; + TranscriptSegment segment = segments[index]; if (predicate(segment)) { matchedSegment = segment; @@ -303,42 +348,42 @@ internal static class WorkflowTranscriptProjector internal sealed class StreamingTranscriptBuffer { - private readonly List _segments = []; + private readonly List _segments = []; public int Count => _segments.Count; - public (string MessageId, string AuthorName, string Content) AppendDelta( + public TranscriptSegment AppendDelta( string messageId, string authorName, string delta) { - StreamingSegment segment = GetOrCreateSegment(messageId, authorName); + BufferedTranscriptSegment 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() + public IReadOnlyList Snapshot() { return _segments.Select(segment => segment.ToSnapshot()).ToList(); } - private StreamingSegment GetOrCreateSegment(string messageId, string authorName) + private BufferedTranscriptSegment GetOrCreateSegment(string messageId, string authorName) { - StreamingSegment? existing = _segments.LastOrDefault(segment => segment.MessageId == messageId); + BufferedTranscriptSegment? existing = _segments.LastOrDefault(segment => segment.MessageId == messageId); if (existing is not null) { return existing; } - StreamingSegment created = new(messageId, authorName); + BufferedTranscriptSegment created = new(messageId, authorName); _segments.Add(created); return created; } - private sealed class StreamingSegment + private sealed class BufferedTranscriptSegment { - public StreamingSegment(string messageId, string authorName) + public BufferedTranscriptSegment(string messageId, string authorName) { MessageId = messageId; AuthorName = authorName; @@ -361,9 +406,9 @@ internal sealed class StreamingTranscriptBuffer AuthorName = value; } - public (string MessageId, string AuthorName, string Content) ToSnapshot() + public TranscriptSegment ToSnapshot() { - return (MessageId, AuthorName, Content.ToString()); + return new TranscriptSegment(MessageId, AuthorName, Content.ToString()); } } } diff --git a/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index cc23cce..e522ca8 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -757,6 +757,8 @@ public sealed class CopilotWorkflowRunnerTests ["tool-call-url"] = "web_fetch", ["tool-call-shell"] = "shell", ["tool-call-read"] = "view", + ["tool-call-write"] = "write_file", + ["tool-call-memory"] = "store_memory", }; Assert.True( @@ -802,6 +804,34 @@ public sealed class CopilotWorkflowRunnerTests toolNamesByCallId, out string? readToolName)); Assert.Equal("view", readToolName); + + Assert.True( + CopilotApprovalCoordinator.TryGetApprovalToolName( + new PermissionRequestWrite + { + Kind = "write", + ToolCallId = "tool-call-write", + Intention = "Update a file", + FileName = "README.md", + Diff = "@@ -1 +1 @@", + }, + toolNamesByCallId, + out string? writeToolName)); + Assert.Equal("write_file", writeToolName); + + Assert.True( + CopilotApprovalCoordinator.TryGetApprovalToolName( + new PermissionRequestMemory + { + Kind = "memory", + ToolCallId = "tool-call-memory", + Subject = "repo conventions", + Fact = "Use Bun for script execution.", + Citations = "package.json", + }, + toolNamesByCallId, + out string? memoryToolName)); + Assert.Equal("store_memory", memoryToolName); } [Fact]