mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-26 22:48:38 +02:00
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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user