mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-28 13:47:12 +02:00
refactor: rename sidecar host to Aryx
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
using System.Collections.Concurrent;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.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(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
string approvalId = RequireApprovalId(command.ApprovalId);
|
||||
PendingApprovalRequest pending = GetPendingApproval(approvalId);
|
||||
PermissionRequestResultKind decision = ParseDecision(command.Decision);
|
||||
|
||||
if (!pending.Decision.TrySetResult(decision))
|
||||
{
|
||||
throw new InvalidOperationException($"Approval \"{approvalId}\" is no longer pending.");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<PermissionRequestResult> RequestApprovalAsync(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
IReadOnlyDictionary<string, string> toolNamesByCallId,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? toolName = ResolveApprovalToolName(request, toolNamesByCallId);
|
||||
if (!RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName))
|
||||
{
|
||||
return CreateApprovalResult(PermissionRequestResultKind.Approved);
|
||||
}
|
||||
|
||||
PendingApprovalRequest pending = CreatePendingApproval(command);
|
||||
if (!_pendingApprovals.TryAdd(pending.ApprovalId, pending))
|
||||
{
|
||||
throw new InvalidOperationException($"Approval \"{pending.ApprovalId}\" is already pending.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await onApproval(BuildPermissionApprovalEvent(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
pending.ApprovalId,
|
||||
toolName))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using CancellationTokenRegistration registration = cancellationToken.Register(
|
||||
static state =>
|
||||
{
|
||||
((TaskCompletionSource<PermissionRequestResultKind>)state!)
|
||||
.TrySetCanceled();
|
||||
},
|
||||
pending.Decision);
|
||||
|
||||
PermissionRequestResultKind decision = await pending.Decision.Task.ConfigureAwait(false);
|
||||
return CreateApprovalResult(decision);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingApprovals.TryRemove(pending.ApprovalId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
internal static ApprovalRequestedEventDto BuildPermissionApprovalEvent(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
string approvalId,
|
||||
string? toolName)
|
||||
{
|
||||
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
|
||||
? "tool access"
|
||||
: request.Kind.Trim();
|
||||
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
|
||||
string? sessionId = NormalizeOptionalString(invocation.SessionId);
|
||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||
string? requestedUrl = request is PermissionRequestUrl urlRequest
|
||||
? NormalizeOptionalString(urlRequest.Url)
|
||||
: null;
|
||||
string title = normalizedToolName is null
|
||||
? $"Approve {permissionKind}"
|
||||
: $"Approve {normalizedToolName}";
|
||||
string detail = normalizedToolName is null
|
||||
? $"{agentName} requested {permissionKind} permission"
|
||||
: $"{agentName} requested {permissionKind} permission for tool \"{normalizedToolName}\"";
|
||||
|
||||
if (requestedUrl is not null)
|
||||
{
|
||||
detail = $"{detail} to access \"{requestedUrl}\"";
|
||||
}
|
||||
|
||||
if (sessionId is not null)
|
||||
{
|
||||
detail = normalizedToolName is null
|
||||
? $"{detail} for Copilot session {sessionId}"
|
||||
: $"{detail} in Copilot session {sessionId}";
|
||||
}
|
||||
|
||||
detail = $"{detail}.";
|
||||
|
||||
return new ApprovalRequestedEventDto
|
||||
{
|
||||
Type = "approval-requested",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ApprovalId = approvalId,
|
||||
ApprovalKind = ToolCallApprovalKind,
|
||||
AgentId = NormalizeOptionalString(agent.Id),
|
||||
AgentName = NormalizeOptionalString(agentName),
|
||||
ToolName = normalizedToolName,
|
||||
PermissionKind = permissionKind,
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
};
|
||||
}
|
||||
|
||||
internal static bool RequiresToolCallApproval(
|
||||
ApprovalPolicyDto? approvalPolicy,
|
||||
string agentId,
|
||||
string? toolName)
|
||||
{
|
||||
if (approvalPolicy?.Rules is null || approvalPolicy.Rules.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!HasMatchingToolCallCheckpoint(approvalPolicy.Rules, agentId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(toolName)
|
||||
|| !approvalPolicy.AutoApprovedToolNames.Any(candidate =>
|
||||
string.Equals(candidate, toolName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
internal static bool TryGetApprovalToolName(
|
||||
PermissionRequest request,
|
||||
IReadOnlyDictionary<string, string>? toolNamesByCallId,
|
||||
out string? 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? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private sealed record PendingApprovalRequest(
|
||||
string RequestId,
|
||||
string SessionId,
|
||||
string ApprovalId,
|
||||
TaskCompletionSource<PermissionRequestResultKind> Decision);
|
||||
}
|
||||
Reference in New Issue
Block a user