refactor: improve sidecar backend structure

Refactor sidecar command dispatch, approval handling, transcript projection, stream merging, and tooling helpers while preserving behavior and expanding backend regression coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-25 20:11:59 +01:00
co-authored by Copilot
parent 4f202274db
commit 1bd0fcba3e
10 changed files with 825 additions and 510 deletions
@@ -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)
@@ -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<string, PendingApprovalRequest> _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<ApprovalRequestedEventDto, Task> 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<PermissionRequestResultKind> 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<PermissionRequestResultKind>)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<string, string>? 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<ApprovalCheckpointRuleDto> 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<PermissionRequestResultKind>(TaskCreationOptions.RunContinuationsAsynchronously));
}
private static PermissionRequestResult CreateApprovalResult(PermissionRequestResultKind decision)
{
return new PermissionRequestResult
{
Kind = decision,
};
}
private static string? ResolveApprovalToolName(
PermissionRequest request,
IReadOnlyDictionary<string, string>? 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<string, string>? 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();
@@ -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<string, string> sanitizedEnvironment = new(StringComparer.OrdinalIgnoreCase);
foreach (KeyValuePair<string, string?> 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<string, string?> 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<string, bool> 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<string> 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<string> GetCandidateFileNames(string? pathExtValue, bool isWindows)
{
yield return CopilotCommandName;
@@ -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<ChatMessage> inputMessages)
{
List<ChatMessage> 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(),
@@ -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<IAsyncDisposable> _disposables = [];
private SessionToolingBundle(
@@ -26,16 +29,8 @@ internal sealed class SessionToolingBundle : IAsyncDisposable
CancellationToken cancellationToken)
{
Dictionary<string, object> mcpServers = BuildMcpServerConfigurations(tooling?.McpServers ?? []);
List<IAsyncDisposable> disposables = [];
List<AIFunction> 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<AIFunction> tools, List<IAsyncDisposable> 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<string> 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<AIFunction> Tools, List<IAsyncDisposable> Disposables)> BuildLspToolingAsync(
IReadOnlyList<RunTurnLspProfileConfigDto> profiles,
string projectPath,
CancellationToken cancellationToken)
{
List<AIFunction> tools = [];
List<IAsyncDisposable> 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<string> ResolveTools(RunTurnMcpServerConfigDto server)
{
return server.Tools.Count == 0 ? [WildcardToolName] : server.Tools.ToList();
}
}
@@ -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<string, Func<CommandContext, Task>> _commandHandlers;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly ConcurrentDictionary<string, Task> _inFlight = new(StringComparer.Ordinal);
@@ -47,6 +53,13 @@ public sealed class SidecarProtocolHost
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true,
};
_commandHandlers = new Dictionary<string, Func<CommandContext, Task>>(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<ValidatePatternCommandDto>(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<RunTurnCommandDto>(rawCommand, _jsonOptions)
?? throw new InvalidOperationException("Could not deserialize run-turn command.");
IReadOnlyList<ChatMessageDto> 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<ResolveApprovalCommandDto>(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<CommandContext, Task>? 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<ValidatePatternCommandDto>(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<RunTurnCommandDto>(context);
IReadOnlyList<ChatMessageDto> 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<ResolveApprovalCommandDto>(context);
await _workflowRunner.ResolveApprovalAsync(command, context.CancellationToken).ConfigureAwait(false);
}
private TCommand DeserializeCommand<TCommand>(CommandContext context)
where TCommand : SidecarCommandEnvelope
{
return JsonSerializer.Deserialize<TCommand>(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<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
{
IReadOnlyList<SidecarModelCapabilityDto> models = [];
IReadOnlyList<SidecarRuntimeToolDto> 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<CapabilityProbeResult> ProbeCapabilitiesAsync(
CopilotCliContext cliContext,
CancellationToken cancellationToken)
{
IReadOnlyList<SidecarModelCapabilityDto> models = [];
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = [];
SidecarCopilotAccountDiagnosticsDto? account = null;
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
Task<SidecarCopilotCliVersionDiagnosticsDto> 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<SidecarModelCapabilityDto> models,
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools,
SidecarConnectionDiagnosticsDto connection)
{
return new SidecarCapabilitiesDto
{
Modes = BuildModeCapabilities(),
@@ -295,6 +341,21 @@ public sealed class SidecarProtocolHost
.ToList();
}
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> 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<IReadOnlyList<SidecarRuntimeToolDto>> 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<SidecarModelCapabilityDto> Models,
IReadOnlyList<SidecarRuntimeToolDto> RuntimeTools,
SidecarConnectionDiagnosticsDto Connection);
}
@@ -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<string> currentTokens = Tokenize(current).ToHashSet(StringComparer.Ordinal);
HashSet<string> 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<string> currentTokens,
HashSet<string> 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;
@@ -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<string, string> 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<string, string> 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<string, string> 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<object>();
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<CodeInterpreterToolCallContent>(out CodeInterpreterToolCallContent? codeInterpreterToolCall))
{
toolName = "code interpreter";
toolName = CodeInterpreterToolName;
toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId);
return true;
}
if (requestData.Is<ImageGenerationToolCallContent>())
{
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<WorkflowRequestHandoffPayload>(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
@@ -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<ChatMessageDto> mapped = [];
return ProjectCompletedMessagesFromSegments(
command,
newMessages,
segments.Select(TranscriptSegment.FromTuple).ToList(),
fallbackAgent);
}
internal static List<ChatMessageDto> ProjectCompletedMessagesFromSegments(
RunTurnCommandDto command,
IReadOnlyList<ChatMessage> newMessages,
IReadOnlyList<TranscriptSegment> segments,
AgentIdentity? fallbackAgent = null)
{
List<ChatMessageDto> 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<TranscriptSegment> preparedSegments = PrepareSegmentsForProjection(command.Pattern, segments);
List<TranscriptSegment> remainingSegments = preparedSegments.ToList();
List<ChatMessage> 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<TranscriptSegment> PrepareSegmentsForProjection(
PatternDefinitionDto pattern,
IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments)
IReadOnlyList<TranscriptSegment> 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<string, ((string MessageId, string AuthorName, string Content) Segment, int LastIndex)> latestSegmentByAuthor =
Dictionary<string, (TranscriptSegment Segment, int LastIndex)> 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<TranscriptSegment> 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<TranscriptSegment> segments,
Func<TranscriptSegment, bool> 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<TranscriptSegment> segments,
Func<TranscriptSegment, bool> 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<StreamingSegment> _segments = [];
private readonly List<BufferedTranscriptSegment> _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<TranscriptSegment> 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());
}
}
}
@@ -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]