feat: add approval checkpoints backend

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-24 19:48:58 +01:00
co-authored by Copilot
parent e7dfb66038
commit 2aa8d73b2d
28 changed files with 1471 additions and 23 deletions
@@ -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);
}
}
}