mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-26 04:43:56 +02:00
feat: add backend plan mode checkpoints
Add backend support for plan-mode turn shaping and exit-plan review checkpoints. Run-turn commands now accept an interaction mode, plan-mode prompt guidance tells agents to produce a plan and call exit_plan_mode, and the sidecar emits a structured exit-plan-mode-requested event when the SDK raises that session event. For now the backend intentionally treats exit_plan_mode as a turn boundary and graceful-degradation review checkpoint. The current Agent Framework GitHubCopilotAgent wrapper does not expose a clean way to bridge the live CopilotSession back into same-turn exit-plan resolution, so that follow-up remains documented for the frontend and a future backend slice. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -161,6 +161,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string ProjectPath { get; init; } = string.Empty;
|
||||
public string WorkspaceKind { get; init; } = "project";
|
||||
public string Mode { get; init; } = "interactive";
|
||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||
@@ -308,6 +309,18 @@ public sealed class UserInputRequestedEventDto : SidecarEventDto
|
||||
public bool? AllowFreeform { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ExitPlanModeRequestedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string ExitPlanId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
public string PlanContent { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string>? Actions { get; init; }
|
||||
public string? RecommendedAction { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CommandErrorEventDto : SidecarEventDto
|
||||
{
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
@@ -8,7 +8,8 @@ internal static class AgentInstructionComposer
|
||||
PatternDefinitionDto pattern,
|
||||
PatternAgentDefinitionDto agent,
|
||||
int agentIndex,
|
||||
string workspaceKind = "project")
|
||||
string workspaceKind = "project",
|
||||
string interactionMode = "interactive")
|
||||
{
|
||||
string baseInstructions = agent.Instructions.Trim();
|
||||
string workspaceGuidance = string.Equals(workspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase)
|
||||
@@ -20,6 +21,14 @@ internal static class AgentInstructionComposer
|
||||
Answer conversationally and focus on the user's question directly.
|
||||
"""
|
||||
: string.Empty;
|
||||
string planModeGuidance = string.Equals(interactionMode, "plan", StringComparison.OrdinalIgnoreCase)
|
||||
? """
|
||||
You are operating in plan mode.
|
||||
Your job in this phase is to analyze the request, identify constraints, and produce a concrete implementation plan instead of carrying out the implementation.
|
||||
Once the plan is ready, call the built-in `exit_plan_mode` tool so the host can present the plan for review.
|
||||
Do not continue into implementation, file edits, builds, or tests after producing the plan unless the user explicitly asks to leave plan mode and proceed.
|
||||
"""
|
||||
: string.Empty;
|
||||
|
||||
if (string.Equals(pattern.Mode, "group-chat", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -37,12 +46,12 @@ internal static class AgentInstructionComposer
|
||||
Focus on refining the answer already in progress.
|
||||
""";
|
||||
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance, groupChatGuidance);
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance, planModeGuidance, groupChatGuidance);
|
||||
}
|
||||
|
||||
if (!string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance);
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance, planModeGuidance);
|
||||
}
|
||||
|
||||
string runtimeGuidance = agentIndex == 0
|
||||
@@ -60,7 +69,7 @@ internal static class AgentInstructionComposer
|
||||
Do not push the actual work back to triage unless you are blocked or the request is clearly outside your specialty.
|
||||
""";
|
||||
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance, runtimeGuidance);
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance, planModeGuidance, runtimeGuidance);
|
||||
}
|
||||
|
||||
private static string JoinInstructionBlocks(params string[] blocks)
|
||||
|
||||
@@ -50,7 +50,12 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
ReasoningEffort = definition.ReasoningEffort,
|
||||
SystemMessage = new SystemMessageConfig
|
||||
{
|
||||
Content = AgentInstructionComposer.Compose(command.Pattern, definition, agentIndex, command.WorkspaceKind),
|
||||
Content = AgentInstructionComposer.Compose(
|
||||
command.Pattern,
|
||||
definition,
|
||||
agentIndex,
|
||||
command.WorkspaceKind,
|
||||
command.Mode),
|
||||
},
|
||||
WorkingDirectory = command.ProjectPath,
|
||||
OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotExitPlanModeCoordinator
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ExitPlanModeRequestedEventDto> _pendingExitPlanRequests =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public ExitPlanModeRequestedEventDto RecordExitPlanModeRequest(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
ExitPlanModeRequestedEvent request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
ExitPlanModeRequestedEventDto exitPlanEvent = BuildExitPlanModeRequestedEvent(command, agent, request);
|
||||
_pendingExitPlanRequests[command.RequestId] = exitPlanEvent;
|
||||
return exitPlanEvent;
|
||||
}
|
||||
|
||||
public ExitPlanModeRequestedEventDto? ConsumePendingRequest(string turnRequestId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(turnRequestId);
|
||||
return _pendingExitPlanRequests.TryRemove(turnRequestId, out ExitPlanModeRequestedEventDto? pending)
|
||||
? pending
|
||||
: null;
|
||||
}
|
||||
|
||||
internal static ExitPlanModeRequestedEventDto BuildExitPlanModeRequestedEvent(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
ExitPlanModeRequestedEvent request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
ExitPlanModeRequestedData requestData = request.Data
|
||||
?? throw new InvalidOperationException("Exit plan mode request data is required.");
|
||||
|
||||
string exitPlanId = NormalizeOptionalString(requestData.RequestId)
|
||||
?? throw new InvalidOperationException("Exit plan mode request ID is required.");
|
||||
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
|
||||
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
|
||||
|
||||
return new ExitPlanModeRequestedEventDto
|
||||
{
|
||||
Type = "exit-plan-mode-requested",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ExitPlanId = exitPlanId,
|
||||
AgentId = normalizedAgentId,
|
||||
AgentName = normalizedAgentName,
|
||||
Summary = NormalizeOptionalString(requestData.Summary) ?? string.Empty,
|
||||
PlanContent = NormalizeOptionalString(requestData.PlanContent) ?? string.Empty,
|
||||
Actions = NormalizeOptionalStringList(requestData.Actions ?? []),
|
||||
RecommendedAction = NormalizeOptionalString(requestData.RecommendedAction),
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? NormalizeOptionalStringList(IEnumerable<string?> values)
|
||||
{
|
||||
List<string> normalized = values
|
||||
.Select(NormalizeOptionalString)
|
||||
.Where(static value => value is not null)
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ internal sealed class CopilotTurnExecutionState
|
||||
|
||||
public List<ChatMessageDto> CompletedMessages { get; private set; } = [];
|
||||
|
||||
public bool HasPendingExitPlanModeRequest { get; private set; }
|
||||
|
||||
public async Task EmitThinkingIfNeeded(
|
||||
AgentIdentity agent,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
@@ -74,6 +76,10 @@ internal sealed class CopilotTurnExecutionState
|
||||
case AssistantReasoningDeltaEvent:
|
||||
ActiveAgent = agent;
|
||||
break;
|
||||
case ExitPlanModeRequestedEvent:
|
||||
HasPendingExitPlanModeRequest = true;
|
||||
ActiveAgent = agent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -9,6 +10,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
private readonly PatternValidator _patternValidator;
|
||||
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
|
||||
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
|
||||
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
|
||||
|
||||
public CopilotWorkflowRunner(PatternValidator patternValidator)
|
||||
{
|
||||
@@ -21,6 +23,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault();
|
||||
@@ -30,42 +33,68 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync(
|
||||
command,
|
||||
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
state.ToolNamesByCallId,
|
||||
onApproval,
|
||||
cancellationToken),
|
||||
(agent, request, invocation) => _userInputCoordinator.RequestUserInputAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
onUserInput,
|
||||
cancellationToken),
|
||||
(agent, sessionEvent) => state.ObserveSessionEvent(agent, sessionEvent),
|
||||
cancellationToken);
|
||||
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||
using CancellationTokenSource runCancellation =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
try
|
||||
{
|
||||
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity)
|
||||
.ConfigureAwait(false);
|
||||
if (shouldEndTurn)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync(
|
||||
command,
|
||||
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
state.ToolNamesByCallId,
|
||||
onApproval,
|
||||
runCancellation.Token),
|
||||
(agent, request, invocation) => _userInputCoordinator.RequestUserInputAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
onUserInput,
|
||||
runCancellation.Token),
|
||||
(agent, sessionEvent) =>
|
||||
{
|
||||
state.ObserveSessionEvent(agent, sessionEvent);
|
||||
if (sessionEvent is ExitPlanModeRequestedEvent exitPlanModeRequested)
|
||||
{
|
||||
_exitPlanModeCoordinator.RecordExitPlanModeRequest(command, agent, exitPlanModeRequested);
|
||||
runCancellation.Cancel();
|
||||
}
|
||||
},
|
||||
runCancellation.Token);
|
||||
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||
|
||||
return state.FinalizeCompletedMessages();
|
||||
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(runCancellation.Token).ConfigureAwait(false))
|
||||
{
|
||||
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity)
|
||||
.ConfigureAwait(false);
|
||||
if (shouldEndTurn)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return state.FinalizeCompletedMessages();
|
||||
}
|
||||
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
ExitPlanModeRequestedEventDto? exitPlanModeEvent =
|
||||
_exitPlanModeCoordinator.ConsumePendingRequest(command.RequestId);
|
||||
if (exitPlanModeEvent is null || !state.HasPendingExitPlanModeRequest)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
await onExitPlanMode(exitPlanModeEvent).ConfigureAwait(false);
|
||||
return state.FinalizeCompletedMessages();
|
||||
}
|
||||
}
|
||||
|
||||
public Task ResolveApprovalAsync(
|
||||
|
||||
@@ -10,6 +10,7 @@ public interface ITurnWorkflowRunner
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ResolveApprovalAsync(
|
||||
|
||||
@@ -21,7 +21,6 @@ public sealed class SidecarProtocolHost
|
||||
AskUserToolName,
|
||||
"report_intent",
|
||||
"task_complete",
|
||||
"exit_plan_mode",
|
||||
};
|
||||
|
||||
private static readonly string[] AuthenticationErrorIndicators =
|
||||
@@ -189,6 +188,7 @@ public sealed class SidecarProtocolHost
|
||||
activity => WriteAsync(context.Output, activity, turnCancellation.Token),
|
||||
approval => WriteAsync(context.Output, approval, turnCancellation.Token),
|
||||
userInput => WriteAsync(context.Output, userInput, turnCancellation.Token),
|
||||
exitPlanMode => WriteAsync(context.Output, exitPlanMode, turnCancellation.Token),
|
||||
turnCancellation.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user