mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 23:48:39 +02:00
feat: add approval checkpoints backend
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -21,11 +21,23 @@ public sealed class PatternDefinitionDto
|
||||
public string Availability { get; init; } = "available";
|
||||
public string? UnavailabilityReason { get; init; }
|
||||
public int MaxIterations { get; init; }
|
||||
public ApprovalPolicyDto? ApprovalPolicy { get; init; }
|
||||
public IReadOnlyList<PatternAgentDefinitionDto> Agents { get; init; } = [];
|
||||
public string CreatedAt { get; init; } = string.Empty;
|
||||
public string UpdatedAt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ApprovalPolicyDto
|
||||
{
|
||||
public IReadOnlyList<ApprovalCheckpointRuleDto> Rules { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class ApprovalCheckpointRuleDto
|
||||
{
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string> AgentIds { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class ChatMessageDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
@@ -116,6 +128,12 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ResolveApprovalCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string ApprovalId { get; init; } = string.Empty;
|
||||
public string Decision { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class RunTurnToolingConfigDto
|
||||
{
|
||||
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
|
||||
@@ -187,6 +205,19 @@ public sealed class AgentActivityEventDto : SidecarEventDto
|
||||
public string? ToolName { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ApprovalRequestedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string ApprovalId { get; init; } = string.Empty;
|
||||
public string ApprovalKind { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string? ToolName { get; init; }
|
||||
public string? PermissionKind { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public string? Detail { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CommandErrorEventDto : SidecarEventDto
|
||||
{
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Eryx.AgentHost.Contracts;
|
||||
@@ -22,6 +23,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
private static readonly Type? ImageGenerationToolCallContentType = LoadType(
|
||||
"Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions");
|
||||
private readonly PatternValidator _patternValidator;
|
||||
private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
|
||||
|
||||
public CopilotWorkflowRunner(PatternValidator patternValidator)
|
||||
{
|
||||
@@ -32,6 +34,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault();
|
||||
@@ -40,7 +43,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
throw new InvalidOperationException(validationError.Message);
|
||||
}
|
||||
|
||||
await using AgentBundle bundle = await AgentBundle.CreateAsync(command, cancellationToken);
|
||||
await using AgentBundle bundle = await AgentBundle.CreateAsync(
|
||||
command,
|
||||
(agent, request, invocation) => RequestApprovalAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
onApproval,
|
||||
cancellationToken),
|
||||
cancellationToken);
|
||||
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(ToChatMessage).ToList();
|
||||
|
||||
@@ -169,6 +181,38 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
return completedMessages;
|
||||
}
|
||||
|
||||
public Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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}\"."),
|
||||
};
|
||||
|
||||
if (!pending.Decision.TrySetResult(decision))
|
||||
{
|
||||
throw new InvalidOperationException($"Approval \"{command.ApprovalId}\" is no longer pending.");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static AgentActivityEventDto CreateActivityEvent(
|
||||
RunTurnCommandDto command,
|
||||
string activityType,
|
||||
@@ -190,6 +234,61 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PermissionRequestResult> RequestApprovalAsync(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!RequiresApproval(command.Pattern.ApprovalPolicy, "tool-call", agent.Id))
|
||||
{
|
||||
return new PermissionRequestResult
|
||||
{
|
||||
Kind = 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))
|
||||
{
|
||||
throw new InvalidOperationException($"Approval \"{approvalId}\" is already pending.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await onApproval(BuildPermissionApprovalEvent(command, agent, request, invocation, approvalId))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using CancellationTokenRegistration registration = cancellationToken.Register(
|
||||
static state =>
|
||||
{
|
||||
((TaskCompletionSource<PermissionRequestResultKind>)state!)
|
||||
.TrySetCanceled();
|
||||
},
|
||||
decisionSource);
|
||||
|
||||
PermissionRequestResultKind decision = await decisionSource.Task.ConfigureAwait(false);
|
||||
return new PermissionRequestResult
|
||||
{
|
||||
Kind = decision,
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingApprovals.TryRemove(approvalId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static AgentActivityEventDto? TryCreateActivityFromRequest(
|
||||
RunTurnCommandDto command,
|
||||
RequestInfoEvent requestInfo,
|
||||
@@ -284,6 +383,75 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ApprovalRequestedEventDto BuildPermissionApprovalEvent(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
string approvalId)
|
||||
{
|
||||
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
|
||||
? "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();
|
||||
|
||||
return new ApprovalRequestedEventDto
|
||||
{
|
||||
Type = "approval-requested",
|
||||
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,
|
||||
PermissionKind = permissionKind,
|
||||
Title = $"Approve {permissionKind}",
|
||||
Detail = sessionId is null
|
||||
? $"{agentName} requested {permissionKind} permission."
|
||||
: $"{agentName} requested {permissionKind} permission for Copilot session {sessionId}.",
|
||||
};
|
||||
}
|
||||
|
||||
private static bool RequiresApproval(
|
||||
ApprovalPolicyDto? approvalPolicy,
|
||||
string checkpointKind,
|
||||
string agentId)
|
||||
{
|
||||
if (approvalPolicy?.Rules is null || approvalPolicy.Rules.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (ApprovalCheckpointRuleDto rule in approvalPolicy.Rules)
|
||||
{
|
||||
if (!string.Equals(rule.Kind, checkpointKind, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rule.AgentIds.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (rule.AgentIds.Any(candidate =>
|
||||
string.Equals(candidate, agentId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string CreateApprovalRequestId()
|
||||
{
|
||||
return $"approval-{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
private static Type? LoadType(string assemblyQualifiedName)
|
||||
{
|
||||
return Type.GetType(assemblyQualifiedName, throwOnError: false);
|
||||
@@ -512,6 +680,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
|
||||
public static async Task<AgentBundle> CreateAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<PatternAgentDefinitionDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<IAsyncDisposable> disposables = [];
|
||||
@@ -542,7 +711,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
Content = AgentInstructionComposer.Compose(command.Pattern, definition, agentIndex, command.WorkspaceKind),
|
||||
},
|
||||
WorkingDirectory = command.ProjectPath,
|
||||
OnPermissionRequest = ApprovePermissionAsync,
|
||||
OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation),
|
||||
Streaming = true,
|
||||
};
|
||||
|
||||
@@ -649,14 +818,11 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static Task<PermissionRequestResult> ApprovePermissionAsync(
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation)
|
||||
{
|
||||
return Task.FromResult(new PermissionRequestResult
|
||||
{
|
||||
Kind = PermissionRequestResultKind.Approved,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PendingApprovalRequest(
|
||||
string RequestId,
|
||||
string SessionId,
|
||||
string ApprovalId,
|
||||
TaskCompletionSource<PermissionRequestResultKind> Decision);
|
||||
}
|
||||
|
||||
@@ -8,5 +8,10 @@ public interface ITurnWorkflowRunner
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ public sealed class SidecarProtocolHost
|
||||
runTurnCommand,
|
||||
delta => WriteAsync(output, delta, cancellationToken),
|
||||
activity => WriteAsync(output, activity, cancellationToken),
|
||||
approval => WriteAsync(output, approval, cancellationToken),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAsync(output, new TurnCompleteEventDto
|
||||
@@ -138,6 +139,15 @@ public sealed class SidecarProtocolHost
|
||||
}, 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}'.");
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ public sealed class SidecarProtocolHostTests
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, cancellationToken) =>
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) =>
|
||||
{
|
||||
await onActivity(new AgentActivityEventDto
|
||||
{
|
||||
@@ -226,6 +226,102 @@ public sealed class SidecarProtocolHostTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTurnCommand_ReturnsApprovalEvents()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) =>
|
||||
{
|
||||
await onApproval(new ApprovalRequestedEventDto
|
||||
{
|
||||
Type = "approval-requested",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ApprovalId = "approval-1",
|
||||
ApprovalKind = "tool-call",
|
||||
AgentId = "agent-1",
|
||||
AgentName = "Primary",
|
||||
PermissionKind = "tool access",
|
||||
Title = "Approve tool access",
|
||||
});
|
||||
|
||||
return [];
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
Type = "run-turn",
|
||||
RequestId = "turn-approval",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = "C:\\workspace\\project",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(name: "Primary"),
|
||||
],
|
||||
},
|
||||
},
|
||||
host);
|
||||
|
||||
Assert.Collection(
|
||||
events,
|
||||
approvalEvent =>
|
||||
{
|
||||
Assert.Equal("approval-requested", approvalEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("turn-approval", approvalEvent.GetProperty("requestId").GetString());
|
||||
Assert.Equal("approval-1", approvalEvent.GetProperty("approvalId").GetString());
|
||||
Assert.Equal("tool-call", approvalEvent.GetProperty("approvalKind").GetString());
|
||||
Assert.Equal("Approve tool access", approvalEvent.GetProperty("title").GetString());
|
||||
},
|
||||
completionEvent =>
|
||||
{
|
||||
Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString());
|
||||
},
|
||||
commandCompleteEvent =>
|
||||
{
|
||||
Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("turn-approval", commandCompleteEvent.GetProperty("requestId").GetString());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveApprovalCommand_DelegatesToWorkflowRunnerAndCompletes()
|
||||
{
|
||||
ResolveApprovalCommandDto? captured = null;
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(
|
||||
handler: async (command, onDelta, onActivity, onApproval, cancellationToken) => [],
|
||||
resolveApprovalHandler: (command, cancellationToken) =>
|
||||
{
|
||||
captured = command;
|
||||
return Task.CompletedTask;
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new ResolveApprovalCommandDto
|
||||
{
|
||||
Type = "resolve-approval",
|
||||
RequestId = "approval-command-1",
|
||||
ApprovalId = "approval-1",
|
||||
Decision = "approved",
|
||||
},
|
||||
host);
|
||||
|
||||
JsonElement completionEvent = Assert.Single(events);
|
||||
Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("approval-command-1", completionEvent.GetProperty("requestId").GetString());
|
||||
Assert.Equal("approval-1", captured?.ApprovalId);
|
||||
Assert.Equal("approved", captured?.Decision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyConnectionStatus_ReturnsAuthRequiredForLoginFailures()
|
||||
{
|
||||
@@ -371,27 +467,40 @@ public sealed class SidecarProtocolHostTests
|
||||
RunTurnCommandDto,
|
||||
Func<TurnDeltaEventDto, Task>,
|
||||
Func<AgentActivityEventDto, Task>,
|
||||
Func<ApprovalRequestedEventDto, Task>,
|
||||
CancellationToken,
|
||||
Task<IReadOnlyList<ChatMessageDto>>> _handler;
|
||||
private readonly Func<ResolveApprovalCommandDto, CancellationToken, Task> _resolveApprovalHandler;
|
||||
|
||||
public FakeWorkflowRunner(
|
||||
Func<
|
||||
RunTurnCommandDto,
|
||||
Func<TurnDeltaEventDto, Task>,
|
||||
Func<AgentActivityEventDto, Task>,
|
||||
Func<ApprovalRequestedEventDto, Task>,
|
||||
CancellationToken,
|
||||
Task<IReadOnlyList<ChatMessageDto>>> handler)
|
||||
Task<IReadOnlyList<ChatMessageDto>>> handler,
|
||||
Func<ResolveApprovalCommandDto, CancellationToken, Task>? resolveApprovalHandler = null)
|
||||
{
|
||||
_handler = handler;
|
||||
_resolveApprovalHandler = resolveApprovalHandler ?? ((_, _) => Task.CompletedTask);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _handler(command, onDelta, onActivity, cancellationToken);
|
||||
return _handler(command, onDelta, onActivity, onApproval, cancellationToken);
|
||||
}
|
||||
|
||||
public Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _resolveApprovalHandler(command, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user