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 internal static class AgentIdentityResolver
{ {
private const string GenericAssistantIdentifier = "assistant";
public static bool TryResolveKnownAgentIdentity( public static bool TryResolveKnownAgentIdentity(
PatternDefinitionDto pattern, PatternDefinitionDto pattern,
string? agentIdentifier, string? agentIdentifier,
out AgentIdentity agent) out AgentIdentity agent)
{ {
agent = default; 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) if (match is null)
{ {
return false; return false;
@@ -62,30 +54,12 @@ internal static class AgentIdentityResolver
string? agentName) string? agentName)
{ {
PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentId) PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentId)
?? FindKnownAgent(pattern, agentName); ?? FindKnownAgent(pattern, agentName)
?? ResolveSingleAgentAssistantAlias(pattern, agentId, agentName);
if (match is null return match is not null
&& pattern.Agents.Count == 1 ? ToAgentIdentity(match)
&& (IsGenericAssistantIdentifier(agentId) || IsGenericAssistantIdentifier(agentName))) : CreateFallbackIdentity(agentId, 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);
} }
public static string ResolveDisplayAuthorName( public static string ResolveDisplayAuthorName(
@@ -113,7 +87,24 @@ internal static class AgentIdentityResolver
return fallbackIdentifier; 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) private static PatternAgentDefinitionDto? FindKnownAgent(PatternDefinitionDto pattern, string? candidate)
@@ -128,6 +119,18 @@ internal static class AgentIdentityResolver
string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name); 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) private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate)
{ {
if (string.IsNullOrWhiteSpace(candidate)) if (string.IsNullOrWhiteSpace(candidate))
@@ -144,7 +147,6 @@ internal static class AgentIdentityResolver
string normalizedCandidate = NormalizeComparisonKey(candidate); string normalizedCandidate = NormalizeComparisonKey(candidate);
string normalizedId = NormalizeComparisonKey(agent.Id); string normalizedId = NormalizeComparisonKey(agent.Id);
string normalizedName = NormalizeComparisonKey(agent.Name); string normalizedName = NormalizeComparisonKey(agent.Name);
if (normalizedCandidate.Length == 0) if (normalizedCandidate.Length == 0)
{ {
return false; return false;
@@ -156,24 +158,11 @@ internal static class AgentIdentityResolver
return true; return true;
} }
if (normalizedId.Length > 0
&& normalizedCandidate.EndsWith(normalizedId, StringComparison.Ordinal))
{
return true;
}
return normalizedId.Length > 0 return normalizedId.Length > 0
&& normalizedName.Length > 0 && normalizedName.Length > 0
&& normalizedCandidate.Contains(normalizedId, StringComparison.Ordinal) && (normalizedCandidate.EndsWith(normalizedId, StringComparison.Ordinal)
&& normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal); || normalizedCandidate.Contains(normalizedId, StringComparison.Ordinal)
} && normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal));
internal static bool IsGenericAssistantIdentifier(string? candidate)
{
return string.Equals(
NormalizeComparisonKey(candidate),
"assistant",
StringComparison.Ordinal);
} }
private static string NormalizeComparisonKey(string? value) private static string NormalizeComparisonKey(string? value)
@@ -6,6 +6,11 @@ namespace Eryx.AgentHost.Services;
internal sealed class CopilotApprovalCoordinator 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); private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
public Task ResolveApprovalAsync( public Task ResolveApprovalAsync(
@@ -14,27 +19,13 @@ internal sealed class CopilotApprovalCoordinator
{ {
ArgumentNullException.ThrowIfNull(command); ArgumentNullException.ThrowIfNull(command);
if (string.IsNullOrWhiteSpace(command.ApprovalId)) string approvalId = RequireApprovalId(command.ApprovalId);
{ PendingApprovalRequest pending = GetPendingApproval(approvalId);
throw new InvalidOperationException("Approval ID is required."); PermissionRequestResultKind decision = ParseDecision(command.Decision);
}
if (!_pendingApprovals.TryGetValue(command.ApprovalId, out PendingApprovalRequest? pending))
{
throw new InvalidOperationException($"Approval \"{command.ApprovalId}\" is not pending.");
}
PermissionRequestResultKind decision = command.Decision.Trim().ToLowerInvariant() switch
{
"approved" => PermissionRequestResultKind.Approved,
"rejected" => PermissionRequestResultKind.DeniedInteractivelyByUser,
_ => throw new InvalidOperationException(
$"Unsupported approval decision \"{command.Decision}\"."),
};
if (!pending.Decision.TrySetResult(decision)) 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; return Task.CompletedTask;
@@ -49,33 +40,27 @@ internal sealed class CopilotApprovalCoordinator
Func<ApprovalRequestedEventDto, Task> onApproval, Func<ApprovalRequestedEventDto, Task> onApproval,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
TryGetApprovalToolName(request, toolNamesByCallId, out string? toolName); string? toolName = ResolveApprovalToolName(request, toolNamesByCallId);
if (!RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName)) if (!RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName))
{ {
return new PermissionRequestResult return CreateApprovalResult(PermissionRequestResultKind.Approved);
{
Kind = PermissionRequestResultKind.Approved,
};
} }
string approvalId = CreateApprovalRequestId(); PendingApprovalRequest pending = CreatePendingApproval(command);
TaskCompletionSource<PermissionRequestResultKind> decisionSource = if (!_pendingApprovals.TryAdd(pending.ApprovalId, pending))
new(TaskCreationOptions.RunContinuationsAsynchronously);
PendingApprovalRequest pending = new(
command.RequestId,
command.SessionId,
approvalId,
decisionSource);
if (!_pendingApprovals.TryAdd(approvalId, pending))
{ {
throw new InvalidOperationException($"Approval \"{approvalId}\" is already pending."); throw new InvalidOperationException($"Approval \"{pending.ApprovalId}\" is already pending.");
} }
try try
{ {
await onApproval(BuildPermissionApprovalEvent(command, agent, request, invocation, approvalId, toolName)) await onApproval(BuildPermissionApprovalEvent(
command,
agent,
request,
invocation,
pending.ApprovalId,
toolName))
.ConfigureAwait(false); .ConfigureAwait(false);
using CancellationTokenRegistration registration = cancellationToken.Register( using CancellationTokenRegistration registration = cancellationToken.Register(
@@ -84,17 +69,14 @@ internal sealed class CopilotApprovalCoordinator
((TaskCompletionSource<PermissionRequestResultKind>)state!) ((TaskCompletionSource<PermissionRequestResultKind>)state!)
.TrySetCanceled(); .TrySetCanceled();
}, },
decisionSource); pending.Decision);
PermissionRequestResultKind decision = await decisionSource.Task.ConfigureAwait(false); PermissionRequestResultKind decision = await pending.Decision.Task.ConfigureAwait(false);
return new PermissionRequestResult return CreateApprovalResult(decision);
{
Kind = decision,
};
} }
finally finally
{ {
_pendingApprovals.TryRemove(approvalId, out _); _pendingApprovals.TryRemove(pending.ApprovalId, out _);
} }
} }
@@ -110,14 +92,10 @@ internal sealed class CopilotApprovalCoordinator
? "tool access" ? "tool access"
: request.Kind.Trim(); : request.Kind.Trim();
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name; string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
string? sessionId = string.IsNullOrWhiteSpace(invocation.SessionId) string? sessionId = NormalizeOptionalString(invocation.SessionId);
? null string? normalizedToolName = NormalizeOptionalString(toolName);
: invocation.SessionId.Trim(); string? requestedUrl = request is PermissionRequestUrl urlRequest
string? normalizedToolName = string.IsNullOrWhiteSpace(toolName) ? NormalizeOptionalString(urlRequest.Url)
? null
: toolName.Trim();
string? requestedUrl = request is PermissionRequestUrl urlRequest && !string.IsNullOrWhiteSpace(urlRequest.Url)
? urlRequest.Url.Trim()
: null; : null;
string title = normalizedToolName is null string title = normalizedToolName is null
? $"Approve {permissionKind}" ? $"Approve {permissionKind}"
@@ -146,9 +124,9 @@ internal sealed class CopilotApprovalCoordinator
RequestId = command.RequestId, RequestId = command.RequestId,
SessionId = command.SessionId, SessionId = command.SessionId,
ApprovalId = approvalId, ApprovalId = approvalId,
ApprovalKind = "tool-call", ApprovalKind = ToolCallApprovalKind,
AgentId = string.IsNullOrWhiteSpace(agent.Id) ? null : agent.Id, AgentId = NormalizeOptionalString(agent.Id),
AgentName = string.IsNullOrWhiteSpace(agentName) ? null : agentName, AgentName = NormalizeOptionalString(agentName),
ToolName = normalizedToolName, ToolName = normalizedToolName,
PermissionKind = permissionKind, PermissionKind = permissionKind,
Title = title, Title = title,
@@ -166,40 +144,14 @@ internal sealed class CopilotApprovalCoordinator
return false; return false;
} }
bool matchesCheckpoint = false; if (!HasMatchingToolCallCheckpoint(approvalPolicy.Rules, agentId))
foreach (ApprovalCheckpointRuleDto rule in approvalPolicy.Rules)
{
if (!string.Equals(rule.Kind, "tool-call", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (rule.AgentIds.Count == 0)
{
matchesCheckpoint = true;
break;
}
if (rule.AgentIds.Any(candidate =>
string.Equals(candidate, agentId, StringComparison.OrdinalIgnoreCase)))
{
matchesCheckpoint = true;
break;
}
}
if (!matchesCheckpoint)
{ {
return false; return false;
} }
if (string.IsNullOrWhiteSpace(toolName)) return string.IsNullOrWhiteSpace(toolName)
{ || !approvalPolicy.AutoApprovedToolNames.Any(candidate =>
return true; string.Equals(candidate, toolName, StringComparison.OrdinalIgnoreCase));
}
return !approvalPolicy.AutoApprovedToolNames.Any(candidate =>
string.Equals(candidate, toolName, StringComparison.OrdinalIgnoreCase));
} }
internal static bool TryGetApprovalToolName( internal static bool TryGetApprovalToolName(
@@ -207,51 +159,149 @@ internal sealed class CopilotApprovalCoordinator
IReadOnlyDictionary<string, string>? toolNamesByCallId, IReadOnlyDictionary<string, string>? toolNamesByCallId,
out string? toolName) out string? toolName)
{ {
toolName = request switch toolName = ResolveApprovalToolName(request, toolNamesByCallId);
{ return toolName is not null;
PermissionRequestMcp mcp when !string.IsNullOrWhiteSpace(mcp.ToolName) => mcp.ToolName.Trim(),
PermissionRequestCustomTool customTool when !string.IsNullOrWhiteSpace(customTool.ToolName) => customTool.ToolName.Trim(),
PermissionRequestHook hook when !string.IsNullOrWhiteSpace(hook.ToolName) => hook.ToolName.Trim(),
_ => null,
};
if (!string.IsNullOrWhiteSpace(toolName))
{
return true;
}
string? toolCallId = NormalizeOptionalString(GetStringProperty(request, "ToolCallId"));
if (toolCallId is not null
&& toolNamesByCallId is not null
&& toolNamesByCallId.TryGetValue(toolCallId, out string? resolvedToolName)
&& !string.IsNullOrWhiteSpace(resolvedToolName))
{
toolName = resolvedToolName.Trim();
return true;
}
toolName = request switch
{
PermissionRequestUrl => "web_fetch",
_ => null,
};
return !string.IsNullOrWhiteSpace(toolName);
} }
internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName) internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName)
=> TryGetApprovalToolName(request, toolNamesByCallId: null, out 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() private static string CreateApprovalRequestId()
{ {
return $"approval-{Guid.NewGuid():N}"; 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) private static string? NormalizeOptionalString(string? value)
{ {
return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
@@ -6,7 +6,9 @@ namespace Eryx.AgentHost.Services;
internal static class CopilotCliPathResolver internal static class CopilotCliPathResolver
{ {
private const string CopilotCommandName = "copilot"; private const string CopilotCommandName = "copilot";
private const string DefaultWindowsCommandProcessor = "cmd.exe";
private const string DefaultWindowsPathExtensions = ".COM;.EXE;.BAT;.CMD"; private const string DefaultWindowsPathExtensions = ".COM;.EXE;.BAT;.CMD";
private static readonly string[] BlockedCliEnvironmentPrefixes = ["BUN_", "COPILOT_", "ELECTRON_", "NODE_", "NPM_"]; private static readonly string[] BlockedCliEnvironmentPrefixes = ["BUN_", "COPILOT_", "ELECTRON_", "NODE_", "NPM_"];
public static CopilotClientOptions CreateClientOptions() public static CopilotClientOptions CreateClientOptions()
@@ -68,21 +70,14 @@ internal static class CopilotCliPathResolver
ArgumentNullException.ThrowIfNull(environmentVariables); ArgumentNullException.ThrowIfNull(environmentVariables);
Dictionary<string, string> sanitizedEnvironment = new(StringComparer.OrdinalIgnoreCase); Dictionary<string, string> sanitizedEnvironment = new(StringComparer.OrdinalIgnoreCase);
foreach (KeyValuePair<string, string?> entry in environmentVariables) foreach (KeyValuePair<string, string?> entry in environmentVariables)
{ {
if (string.IsNullOrWhiteSpace(entry.Key) || entry.Value is null) if (ShouldSkipEnvironmentEntry(entry))
{ {
continue; continue;
} }
string normalizedKey = entry.Key.ToUpperInvariant(); sanitizedEnvironment[entry.Key] = entry.Value!;
if (BlockedCliEnvironmentPrefixes.Any(prefix => normalizedKey.StartsWith(prefix, StringComparison.Ordinal)))
{
continue;
}
sanitizedEnvironment[entry.Key] = entry.Value;
} }
return sanitizedEnvironment; return sanitizedEnvironment;
@@ -97,33 +92,36 @@ internal static class CopilotCliPathResolver
return new CopilotCliLaunch(cliPath, []); return new CopilotCliLaunch(cliPath, []);
} }
string launchPath = string.IsNullOrWhiteSpace(commandProcessorPath)
? "cmd.exe"
: commandProcessorPath;
return new CopilotCliLaunch( return new CopilotCliLaunch(
launchPath, ResolveCommandProcessorPath(commandProcessorPath),
["/d", "/s", "/c", CopilotCommandName]); ["/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( private static string? ResolveCliPath(
string? pathValue, string? pathValue,
string? pathExtValue, string? pathExtValue,
bool isWindows, bool isWindows,
Func<string, bool> fileExists) Func<string, bool> fileExists)
{ {
if (string.IsNullOrWhiteSpace(pathValue)) foreach (string directory in EnumerateDistinctSearchDirectories(pathValue, isWindows))
{
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 candidateName in GetCandidateFileNames(pathExtValue, isWindows)) foreach (string candidateName in GetCandidateFileNames(pathExtValue, isWindows))
{ {
@@ -138,6 +136,24 @@ internal static class CopilotCliPathResolver
return null; 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) private static IEnumerable<string> GetCandidateFileNames(string? pathExtValue, bool isWindows)
{ {
yield return CopilotCommandName; yield return CopilotCommandName;
@@ -89,7 +89,7 @@ internal sealed class CopilotTurnExecutionState
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}"; return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
} }
public (string MessageId, string AuthorName, string Content) AppendDelta( public TranscriptSegment AppendDelta(
string messageId, string messageId,
string authorName, string authorName,
string delta) string delta)
@@ -117,7 +117,7 @@ internal sealed class CopilotTurnExecutionState
IReadOnlyList<ChatMessage> inputMessages) IReadOnlyList<ChatMessage> inputMessages)
{ {
List<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(allMessages, inputMessages); List<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(allMessages, inputMessages);
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessages( CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
_command, _command,
newMessages, newMessages,
_transcriptBuffer.Snapshot(), _transcriptBuffer.Snapshot(),
@@ -128,7 +128,7 @@ internal sealed class CopilotTurnExecutionState
{ {
if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0) if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0)
{ {
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessages( CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
_command, _command,
[], [],
_transcriptBuffer.Snapshot(), _transcriptBuffer.Snapshot(),
@@ -6,6 +6,9 @@ namespace Eryx.AgentHost.Services;
internal sealed class SessionToolingBundle : IAsyncDisposable internal sealed class SessionToolingBundle : IAsyncDisposable
{ {
private const string LocalTransport = "local";
private const string WildcardToolName = "*";
private readonly List<IAsyncDisposable> _disposables = []; private readonly List<IAsyncDisposable> _disposables = [];
private SessionToolingBundle( private SessionToolingBundle(
@@ -26,16 +29,8 @@ internal sealed class SessionToolingBundle : IAsyncDisposable
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Dictionary<string, object> mcpServers = BuildMcpServerConfigurations(tooling?.McpServers ?? []); Dictionary<string, object> mcpServers = BuildMcpServerConfigurations(tooling?.McpServers ?? []);
List<IAsyncDisposable> disposables = []; (List<AIFunction> tools, List<IAsyncDisposable> disposables) =
List<AIFunction> tools = []; await BuildLspToolingAsync(tooling?.LspProfiles ?? [], projectPath, cancellationToken).ConfigureAwait(false);
foreach (RunTurnLspProfileConfigDto profile in tooling?.LspProfiles ?? [])
{
LspToolSession lspSession = await LspToolSession.StartAsync(profile, projectPath, cancellationToken)
.ConfigureAwait(false);
disposables.Add(lspSession);
tools.AddRange(lspSession.Tools);
}
SessionToolingBundle bundle = new(mcpServers, tools); SessionToolingBundle bundle = new(mcpServers, tools);
bundle._disposables.AddRange(disposables); bundle._disposables.AddRange(disposables);
@@ -57,42 +52,81 @@ internal sealed class SessionToolingBundle : IAsyncDisposable
foreach (RunTurnMcpServerConfigDto server in servers) foreach (RunTurnMcpServerConfigDto server in servers)
{ {
string serverName = string.IsNullOrWhiteSpace(server.Name) ? server.Id : server.Name.Trim(); configurations[ResolveServerName(server)] = CreateServerConfiguration(server);
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,
};
} }
return configurations; 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 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 = private static readonly string[] AuthenticationErrorIndicators =
[ [
"login", "login",
@@ -26,6 +31,7 @@ public sealed class SidecarProtocolHost
private readonly PatternValidator _patternValidator; private readonly PatternValidator _patternValidator;
private readonly ITurnWorkflowRunner _workflowRunner; private readonly ITurnWorkflowRunner _workflowRunner;
private readonly JsonSerializerOptions _jsonOptions; private readonly JsonSerializerOptions _jsonOptions;
private readonly IReadOnlyDictionary<string, Func<CommandContext, Task>> _commandHandlers;
private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly ConcurrentDictionary<string, Task> _inFlight = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, Task> _inFlight = new(StringComparer.Ordinal);
@@ -47,6 +53,13 @@ public sealed class SidecarProtocolHost
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true, 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) public async Task RunAsync(TextReader input, TextWriter output, CancellationToken cancellationToken)
@@ -65,17 +78,9 @@ public sealed class SidecarProtocolHost
} }
SidecarCommandEnvelope envelope = DeserializeEnvelope(line); SidecarCommandEnvelope envelope = DeserializeEnvelope(line);
Task task = HandleCommandAsync(line, envelope, output, cancellationToken); TrackInFlightRequest(
_inFlight[envelope.RequestId] = task; envelope.RequestId,
_ = task.ContinueWith( HandleCommandAsync(line, envelope, output, cancellationToken));
_ =>
{
_inFlight.TryRemove(envelope.RequestId, out Task? removedTask);
return removedTask is not null;
},
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default);
} }
await Task.WhenAll(_inFlight.Values).ConfigureAwait(false); await Task.WhenAll(_inFlight.Values).ConfigureAwait(false);
@@ -87,89 +92,124 @@ public sealed class SidecarProtocolHost
?? throw new InvalidOperationException("Could not deserialize sidecar command envelope."); ?? 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( private async Task HandleCommandAsync(
string rawCommand, string rawCommand,
SidecarCommandEnvelope envelope, SidecarCommandEnvelope envelope,
TextWriter output, TextWriter output,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
CommandContext context = new(rawCommand, envelope, output, cancellationToken);
try try
{ {
switch (envelope.Type) await ExecuteCommandAsync(context).ConfigureAwait(false);
{ await WriteCommandCompleteAsync(context).ConfigureAwait(false);
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);
} }
catch (Exception ex) catch (Exception ex)
{ {
await WriteAsync(output, new CommandErrorEventDto await WriteCommandErrorAsync(context, ex.Message).ConfigureAwait(false);
{
Type = "command-error",
RequestId = envelope.RequestId,
Message = ex.Message,
}, cancellationToken).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) private async Task WriteAsync(TextWriter output, object payload, CancellationToken cancellationToken)
{ {
string json = JsonSerializer.Serialize(payload, _jsonOptions); string json = JsonSerializer.Serialize(payload, _jsonOptions);
@@ -187,30 +227,28 @@ public sealed class SidecarProtocolHost
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken) 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 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) catch (Exception exception)
{ {
connection = CreateMissingCliDiagnostics(exception); SidecarConnectionDiagnosticsDto connection = CreateMissingCliDiagnostics(exception);
Console.Error.WriteLine($"[eryx sidecar] {connection.Summary} {exception.Message}"); Console.Error.WriteLine($"[eryx sidecar] {connection.Summary} {exception.Message}");
return CreateCapabilities([], [], connection);
return new SidecarCapabilitiesDto
{
Modes = BuildModeCapabilities(),
Models = models,
Connection = 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 = Task<SidecarCopilotCliVersionDiagnosticsDto> cliVersionTask =
CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken); CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken);
@@ -224,29 +262,37 @@ public sealed class SidecarProtocolHost
GetAuthStatusResponse? authStatus = GetAuthStatusResponse? authStatus =
await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false); await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false);
account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync( account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync(
authStatus, authStatus,
cliContext.Environment, cliContext.Environment,
cancellationToken).ConfigureAwait(false); cancellationToken)
.ConfigureAwait(false);
models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false); models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false);
try runtimeTools = await TryListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
{
runtimeTools = await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[eryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}");
}
cliVersion = await cliVersionTask.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) catch (Exception exception)
{ {
cliVersion = await cliVersionTask.ConfigureAwait(false); 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}"); 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 return new SidecarCapabilitiesDto
{ {
Modes = BuildModeCapabilities(), Modes = BuildModeCapabilities(),
@@ -295,6 +341,21 @@ public sealed class SidecarProtocolHost
.ToList(); .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( private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> ListAvailableRuntimeToolsAsync(
CopilotClient client, CopilotClient client,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -388,4 +449,15 @@ public sealed class SidecarProtocolHost
return "copilot-error"; 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 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) public static string Merge(string current, string incoming)
{ {
if (string.IsNullOrEmpty(current)) if (string.IsNullOrEmpty(current))
@@ -16,21 +21,10 @@ internal static partial class StreamingTextMerger
return current; return current;
} }
if (incoming.StartsWith(current, StringComparison.Ordinal) if (TryMergeSnapshotVariants(current, incoming, out string merged)
|| incoming.Contains(current, StringComparison.Ordinal)) || TryMergeByOverlap(current, incoming, out merged))
{ {
return incoming; return merged;
}
if (current.Contains(incoming, StringComparison.Ordinal))
{
return current;
}
int overlap = ComputeSuffixPrefixOverlap(current, incoming);
if (overlap > 0)
{
return current + incoming[overlap..];
} }
if (ShouldReplaceWithSnapshot(current, incoming)) if (ShouldReplaceWithSnapshot(current, incoming))
@@ -38,22 +32,51 @@ internal static partial class StreamingTextMerger
return incoming; 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)) if (ShouldInsertNewlineBoundary(current, incoming))
{ {
return current + "\n" + incoming; return "\n";
} }
if (ShouldInsertSpaceBoundary(current, incoming)) return ShouldInsertSpaceBoundary(current, incoming)
{ ? " "
return current + " " + incoming; : string.Empty;
}
return current + incoming;
} }
private static int ComputeSuffixPrefixOverlap(string current, string incoming) 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) private static bool ShouldReplaceWithSnapshot(string current, string incoming)
{ {
if (incoming.Length < Math.Floor(current.Length * 0.6)) if (!HasViableSnapshotLength(current, incoming))
{ {
return false; return false;
} }
HashSet<string> currentTokens = Tokenize(current).ToHashSet(StringComparer.Ordinal); HashSet<string> currentTokens = Tokenize(current).ToHashSet(StringComparer.Ordinal);
HashSet<string> incomingTokens = Tokenize(incoming).ToHashSet(StringComparer.Ordinal); HashSet<string> incomingTokens = Tokenize(incoming).ToHashSet(StringComparer.Ordinal);
if (currentTokens.Count < 3 || incomingTokens.Count < 3) if (!HasEnoughTokensForSnapshotComparison(currentTokens, incomingTokens))
{ {
return false; return false;
} }
int shared = incomingTokens.Count(token => currentTokens.Contains(token)); int sharedTokenCount = incomingTokens.Count(token => currentTokens.Contains(token));
return shared / (double)Math.Min(currentTokens.Count, incomingTokens.Count) >= 0.5; 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) private static bool ShouldInsertNewlineBoundary(string current, string incoming)
{ {
if (current.EndsWith('\n')) return !current.EndsWith('\n')
{ && MarkdownBlockPrefixRegex().IsMatch(incoming.TrimStart());
return false;
}
return MarkdownBlockPrefixRegex().IsMatch(incoming.TrimStart());
} }
private static bool ShouldInsertSpaceBoundary(string current, string incoming) private static bool ShouldInsertSpaceBoundary(string current, string incoming)
{ {
char lastChar = current[^1]; char lastCharacter = current[^1];
char firstChar = incoming[0]; char firstCharacter = incoming[0];
if (HasExistingBoundary(lastCharacter, firstCharacter)
if (char.IsWhiteSpace(lastChar) || CharactersThatDoNotNeedLeadingSpace.Contains(lastCharacter))
|| char.IsWhiteSpace(firstChar)
|| "([{/\"'`".Contains(lastChar))
{ {
return false; return false;
} }
@@ -115,13 +146,24 @@ internal static partial class StreamingTextMerger
return false; return false;
} }
if (MarkdownInlinePrefixRegex().IsMatch(incoming) return StartsLikeASeparatedInlineFragment(firstCharacter, incoming)
|| char.IsUpper(firstChar) || LooksLikeWordBoundary(current, incoming);
|| char.IsDigit(firstChar)) }
{
return true;
}
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[] currentTokens = Tokenize(current).ToArray();
string[] incomingTokens = Tokenize(incoming).ToArray(); string[] incomingTokens = Tokenize(incoming).ToArray();
string firstIncomingToken = incomingTokens.FirstOrDefault() ?? string.Empty; string firstIncomingToken = incomingTokens.FirstOrDefault() ?? string.Empty;
@@ -8,47 +8,25 @@ namespace Eryx.AgentHost.Services;
internal static class WorkflowRequestInfoInterpreter 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( public static AgentActivityEventDto? TryCreateActivityFromRequest(
RunTurnCommandDto command, RunTurnCommandDto command,
RequestInfoEvent requestInfo, RequestInfoEvent requestInfo,
AgentIdentity? activeAgent, AgentIdentity? activeAgent,
ConcurrentDictionary<string, string> toolNamesByCallId) ConcurrentDictionary<string, string> toolNamesByCallId)
{ {
if (TryGetHandoffTarget(command.Pattern, requestInfo, out AgentIdentity handoffAgent)) RequestInterpretation interpretation = InterpretRequest(command.Pattern, requestInfo);
return interpretation switch
{ {
return new AgentActivityEventDto HandoffRequestInterpretation handoff =>
{ CreateHandoffActivity(command, handoff.TargetAgent, activeAgent),
Type = "agent-activity", ToolRequestInterpretation tool when activeAgent.HasValue =>
RequestId = command.RequestId, CreateToolCallingActivity(command, activeAgent.Value, tool, toolNamesByCallId),
SessionId = command.SessionId, _ => null,
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,
}; };
} }
@@ -56,17 +34,71 @@ internal static class WorkflowRequestInfoInterpreter
RunTurnCommandDto command, RunTurnCommandDto command,
RequestInfoEvent requestInfo) 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 TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId)
{ ? new ToolRequestInterpretation(toolName, toolCallId)
return false; : new UnknownRequestInterpretation();
}
return !TryGetToolRequestInfo(requestInfo, out _, out _);
} }
private static bool TryGetHandoffTarget( private static bool TryGetHandoffTarget(
@@ -75,6 +107,7 @@ internal static class WorkflowRequestInfoInterpreter
out AgentIdentity agent) out AgentIdentity agent)
{ {
agent = default; agent = default;
object? handoffValue = requestInfo.Request.Data.As<object>(); object? handoffValue = requestInfo.Request.Data.As<object>();
if (handoffValue is null) if (handoffValue is null)
{ {
@@ -99,12 +132,8 @@ internal static class WorkflowRequestInfoInterpreter
out string toolName, out string toolName,
out string? toolCallId) out string? toolCallId)
{ {
if (TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId)) return TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId)
{ || TryGetEvaluationToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId);
return true;
}
return TryGetEvaluationToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId);
} }
private static bool TryGetStableToolRequestInfo( private static bool TryGetStableToolRequestInfo(
@@ -135,19 +164,19 @@ internal static class WorkflowRequestInfoInterpreter
?? NormalizeOptionalString(mcpToolCall.ServerName) ?? NormalizeOptionalString(mcpToolCall.ServerName)
?? string.Empty; ?? string.Empty;
toolCallId = NormalizeOptionalString(mcpToolCall.CallId); toolCallId = NormalizeOptionalString(mcpToolCall.CallId);
return !string.IsNullOrWhiteSpace(toolName); return toolName.Length > 0;
} }
if (requestData.Is<CodeInterpreterToolCallContent>(out CodeInterpreterToolCallContent? codeInterpreterToolCall)) if (requestData.Is<CodeInterpreterToolCallContent>(out CodeInterpreterToolCallContent? codeInterpreterToolCall))
{ {
toolName = "code interpreter"; toolName = CodeInterpreterToolName;
toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId); toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId);
return true; return true;
} }
if (requestData.Is<ImageGenerationToolCallContent>()) if (requestData.Is<ImageGenerationToolCallContent>())
{ {
toolName = "image generation"; toolName = ImageGenerationToolName;
toolCallId = null; toolCallId = null;
return true; return true;
} }
@@ -167,6 +196,14 @@ internal static class WorkflowRequestInfoInterpreter
string json = JsonSerializer.Serialize(handoffValue, handoffValue.GetType()); string json = JsonSerializer.Serialize(handoffValue, handoffValue.GetType());
return JsonSerializer.Deserialize<WorkflowRequestHandoffPayload>(json); 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 internal sealed class WorkflowRequestHandoffPayload
@@ -4,6 +4,12 @@ using Microsoft.Extensions.AI;
namespace Eryx.AgentHost.Services; 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 internal static class WorkflowTranscriptProjector
{ {
public static ChatMessage ToChatMessage(ChatMessageDto message) public static ChatMessage ToChatMessage(ChatMessageDto message)
@@ -29,68 +35,107 @@ internal static class WorkflowTranscriptProjector
IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments, IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments,
AgentIdentity? fallbackAgent = null) 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; int fallbackOutputIndex = 0;
string createdAt = DateTimeOffset.UtcNow.ToString("O"); string createdAt = DateTimeOffset.UtcNow.ToString("O");
List<(string MessageId, string AuthorName, string Content)> preparedSegments = List<TranscriptSegment> preparedSegments = PrepareSegmentsForProjection(command.Pattern, segments);
PrepareSegmentsForProjection(command.Pattern, segments); List<TranscriptSegment> remainingSegments = preparedSegments.ToList();
List<(string MessageId, string AuthorName, string Content)> remainingSegments = preparedSegments.ToList();
List<ChatMessage> assistantMessages = newMessages.Where(message => message.Role != ChatRole.User).ToList(); List<ChatMessage> assistantMessages = newMessages.Where(message => message.Role != ChatRole.User).ToList();
for (int messageIndex = 0; messageIndex < assistantMessages.Count; messageIndex++) for (int messageIndex = 0; messageIndex < assistantMessages.Count; messageIndex++)
{ {
ChatMessage message = assistantMessages[messageIndex]; ChatMessage message = assistantMessages[messageIndex];
(string MessageId, string AuthorName, string Content)? segment = TryMatchSegment( TranscriptSegment? matchedSegment = TryMatchSegment(
message, message,
remainingSegments, remainingSegments,
assistantMessages.Count - messageIndex, assistantMessages.Count - messageIndex,
command.Pattern, command.Pattern,
fallbackAgent); fallbackAgent);
string content = message.Text ?? segment?.Content ?? string.Empty; string content = message.Text ?? matchedSegment?.Content ?? string.Empty;
if (string.IsNullOrWhiteSpace(content)) if (string.IsNullOrWhiteSpace(content))
{ {
continue; continue;
} }
if (segment.HasValue) if (matchedSegment.HasValue)
{ {
remainingSegments.Remove(segment.Value); remainingSegments.Remove(matchedSegment.Value);
} }
fallbackOutputIndex++; fallbackOutputIndex++;
projectedMessages.Add(CreateProjectedMessage(
mapped.Add(new ChatMessageDto command,
{ message,
Id = segment?.MessageId ?? $"{command.RequestId}-final-{fallbackOutputIndex}", matchedSegment,
Role = message.Role == ChatRole.System ? "system" : "assistant", fallbackAgent,
AuthorName = ResolveProjectedAuthorName( createdAt,
command.Pattern, fallbackOutputIndex,
message.AuthorName, content));
segment?.AuthorName,
fallbackAgent),
Content = content,
CreatedAt = createdAt,
});
} }
if (mapped.Count == 0 && preparedSegments.Count > 0) if (projectedMessages.Count == 0 && preparedSegments.Count > 0)
{ {
mapped.AddRange(preparedSegments.Select(segment => new ChatMessageDto projectedMessages.AddRange(preparedSegments.Select(segment =>
{ CreateProjectedMessageFromSegment(command, segment, createdAt)));
Id = segment.MessageId,
Role = "assistant",
AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Pattern, segment.AuthorName),
Content = segment.Content,
CreatedAt = 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, PatternDefinitionDto pattern,
IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments) IReadOnlyList<TranscriptSegment> segments)
{ {
if (!string.Equals(pattern.Mode, "concurrent", StringComparison.Ordinal) if (!string.Equals(pattern.Mode, "concurrent", StringComparison.Ordinal)
|| segments.Count <= 1) || segments.Count <= 1)
@@ -101,12 +146,12 @@ internal static class WorkflowTranscriptProjector
// Agent Framework concurrent workflows aggregate the last message emitted by each agent. // 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 // 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. // 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); new(StringComparer.Ordinal);
for (int index = 0; index < segments.Count; index++) 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); string authorKey = AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName);
latestSegmentByAuthor[authorKey] = (segment, index); latestSegmentByAuthor[authorKey] = (segment, index);
} }
@@ -117,9 +162,9 @@ internal static class WorkflowTranscriptProjector
.ToList(); .ToList();
} }
private static (string MessageId, string AuthorName, string Content)? TryMatchSegment( private static TranscriptSegment? TryMatchSegment(
ChatMessage message, ChatMessage message,
IReadOnlyList<(string MessageId, string AuthorName, string Content)> remainingSegments, IReadOnlyList<TranscriptSegment> remainingSegments,
int remainingMessageCount, int remainingMessageCount,
PatternDefinitionDto pattern, PatternDefinitionDto pattern,
AgentIdentity? fallbackAgent) AgentIdentity? fallbackAgent)
@@ -145,7 +190,7 @@ internal static class WorkflowTranscriptProjector
AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName), AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName),
resolvedAuthorName, resolvedAuthorName,
StringComparison.Ordinal), StringComparison.Ordinal),
out (string MessageId, string AuthorName, string Content) authorMatchedSegment)) out TranscriptSegment authorMatchedSegment))
{ {
return authorMatchedSegment; return authorMatchedSegment;
} }
@@ -153,7 +198,7 @@ internal static class WorkflowTranscriptProjector
if (TryFindSegment( if (TryFindSegment(
remainingSegments, remainingSegments,
segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal), segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal),
out (string MessageId, string AuthorName, string Content) contentMatchedSegment)) out TranscriptSegment contentMatchedSegment))
{ {
return contentMatchedSegment; return contentMatchedSegment;
} }
@@ -169,7 +214,7 @@ internal static class WorkflowTranscriptProjector
AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName), AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName),
fallbackAgent.Value.AgentName, fallbackAgent.Value.AgentName,
StringComparison.Ordinal), StringComparison.Ordinal),
out (string MessageId, string AuthorName, string Content) fallbackMatchedSegment)) out TranscriptSegment fallbackMatchedSegment))
{ {
return fallbackMatchedSegment; return fallbackMatchedSegment;
} }
@@ -183,11 +228,11 @@ internal static class WorkflowTranscriptProjector
} }
private static bool TryFindSegment( private static bool TryFindSegment(
IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments, IReadOnlyList<TranscriptSegment> segments,
Func<(string MessageId, string AuthorName, string Content), bool> predicate, Func<TranscriptSegment, bool> predicate,
out (string MessageId, string AuthorName, string Content) matchedSegment) out TranscriptSegment matchedSegment)
{ {
foreach ((string MessageId, string AuthorName, string Content) segment in segments) foreach (TranscriptSegment segment in segments)
{ {
if (predicate(segment)) if (predicate(segment))
{ {
@@ -201,13 +246,13 @@ internal static class WorkflowTranscriptProjector
} }
private static bool TryFindLastSegment( private static bool TryFindLastSegment(
IReadOnlyList<(string MessageId, string AuthorName, string Content)> segments, IReadOnlyList<TranscriptSegment> segments,
Func<(string MessageId, string AuthorName, string Content), bool> predicate, Func<TranscriptSegment, bool> predicate,
out (string MessageId, string AuthorName, string Content) matchedSegment) out TranscriptSegment matchedSegment)
{ {
for (int index = segments.Count - 1; index >= 0; index--) 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)) if (predicate(segment))
{ {
matchedSegment = segment; matchedSegment = segment;
@@ -303,42 +348,42 @@ internal static class WorkflowTranscriptProjector
internal sealed class StreamingTranscriptBuffer internal sealed class StreamingTranscriptBuffer
{ {
private readonly List<StreamingSegment> _segments = []; private readonly List<BufferedTranscriptSegment> _segments = [];
public int Count => _segments.Count; public int Count => _segments.Count;
public (string MessageId, string AuthorName, string Content) AppendDelta( public TranscriptSegment AppendDelta(
string messageId, string messageId,
string authorName, string authorName,
string delta) string delta)
{ {
StreamingSegment segment = GetOrCreateSegment(messageId, authorName); BufferedTranscriptSegment segment = GetOrCreateSegment(messageId, authorName);
segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), delta)); segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), delta));
segment.SetAuthorName(authorName); segment.SetAuthorName(authorName);
return segment.ToSnapshot(); return segment.ToSnapshot();
} }
public IReadOnlyList<(string MessageId, string AuthorName, string Content)> Snapshot() public IReadOnlyList<TranscriptSegment> Snapshot()
{ {
return _segments.Select(segment => segment.ToSnapshot()).ToList(); 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) if (existing is not null)
{ {
return existing; return existing;
} }
StreamingSegment created = new(messageId, authorName); BufferedTranscriptSegment created = new(messageId, authorName);
_segments.Add(created); _segments.Add(created);
return 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; MessageId = messageId;
AuthorName = authorName; AuthorName = authorName;
@@ -361,9 +406,9 @@ internal sealed class StreamingTranscriptBuffer
AuthorName = value; 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-url"] = "web_fetch",
["tool-call-shell"] = "shell", ["tool-call-shell"] = "shell",
["tool-call-read"] = "view", ["tool-call-read"] = "view",
["tool-call-write"] = "write_file",
["tool-call-memory"] = "store_memory",
}; };
Assert.True( Assert.True(
@@ -802,6 +804,34 @@ public sealed class CopilotWorkflowRunnerTests
toolNamesByCallId, toolNamesByCallId,
out string? readToolName)); out string? readToolName));
Assert.Equal("view", 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] [Fact]