feat: implement execution control (pause/resume/abort) for agent mode
Adds execution control system per GitHub issue #113: - Ctrl+P: Toggle pause/resume during agent execution - Ctrl+Z: Abort with rollback (undo file changes) - Ctrl+Shift+S: Toggle step-by-step mode - Enter: Advance one step when in step mode New files: - src/types/execution-control.ts: Type definitions - src/services/execution-control.ts: Control implementation with rollback - src/constants/execution-control.ts: Keyboard shortcuts and messages Modified: - agent-stream.ts: Integrated execution control into agent loop - message-handler.ts: Added control functions and callbacks - app.tsx: Added keyboard shortcut handlers - help-content.ts: Added help topics for new shortcuts Closes #113
This commit is contained in:
53
src/constants/execution-control.ts
Normal file
53
src/constants/execution-control.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Execution Control Constants
|
||||
*
|
||||
* Constants for agent execution control (pause/resume/abort).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Keyboard shortcuts for execution control
|
||||
*/
|
||||
export const EXECUTION_CONTROL_KEYS = {
|
||||
/** Toggle pause/resume (Ctrl+P) */
|
||||
TOGGLE_PAUSE: "ctrl+p",
|
||||
/** Abort execution (Ctrl+C) */
|
||||
ABORT: "ctrl+c",
|
||||
/** Abort with rollback (Ctrl+Z) */
|
||||
ABORT_WITH_ROLLBACK: "ctrl+z",
|
||||
/** Toggle step mode (Ctrl+Shift+S) */
|
||||
TOGGLE_STEP_MODE: "ctrl+shift+s",
|
||||
/** Advance one step in step mode (Enter when waiting) */
|
||||
STEP: "return",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Execution control status messages
|
||||
*/
|
||||
export const EXECUTION_CONTROL_MESSAGES = {
|
||||
PAUSED: "⏸ Execution paused. Press Ctrl+P to resume.",
|
||||
RESUMED: "▶ Execution resumed.",
|
||||
STEP_MODE_ENABLED: "🚶 Step mode enabled. Press Enter to advance each tool call.",
|
||||
STEP_MODE_DISABLED: "🏃 Step mode disabled. Execution will continue automatically.",
|
||||
WAITING_FOR_STEP: (toolName: string): string =>
|
||||
`⏳ Step mode: Ready to execute ${toolName}. Press Enter to continue.`,
|
||||
ROLLBACK_ACTION: (description: string): string =>
|
||||
`↩ Rolling back: ${description}`,
|
||||
ROLLBACK_COMPLETE: (count: number): string =>
|
||||
`✓ Rollback complete. ${count} action(s) undone.`,
|
||||
ABORT_INITIATED: (rollbackCount: number): string =>
|
||||
rollbackCount > 0
|
||||
? `Aborting with rollback of ${rollbackCount} action(s)...`
|
||||
: "Aborting execution...",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Help text for execution control
|
||||
*/
|
||||
export const EXECUTION_CONTROL_HELP = `
|
||||
Execution Control:
|
||||
Ctrl+P - Toggle pause/resume
|
||||
Ctrl+C - Abort execution
|
||||
Ctrl+Z - Abort with rollback (undo file changes)
|
||||
Ctrl+Shift+S - Toggle step-by-step mode
|
||||
Enter - Advance one step (when in step mode)
|
||||
` as const;
|
||||
@@ -173,6 +173,33 @@ export const HELP_TOPICS: HelpTopic[] = [
|
||||
shortcuts: ["Ctrl+M"],
|
||||
category: "shortcuts",
|
||||
},
|
||||
{
|
||||
id: "shortcut-ctrlp",
|
||||
name: "Ctrl+P",
|
||||
shortDescription: "Pause/Resume",
|
||||
fullDescription:
|
||||
"Toggle pause/resume during agent execution. When paused, tool calls are suspended until resumed.",
|
||||
shortcuts: ["Ctrl+P"],
|
||||
category: "shortcuts",
|
||||
},
|
||||
{
|
||||
id: "shortcut-ctrlz",
|
||||
name: "Ctrl+Z",
|
||||
shortDescription: "Abort with rollback",
|
||||
fullDescription:
|
||||
"Abort current operation and rollback file changes made during this execution. Use to undo unwanted modifications.",
|
||||
shortcuts: ["Ctrl+Z"],
|
||||
category: "shortcuts",
|
||||
},
|
||||
{
|
||||
id: "shortcut-ctrlshifts",
|
||||
name: "Ctrl+Shift+S",
|
||||
shortDescription: "Toggle step mode",
|
||||
fullDescription:
|
||||
"Enable step-by-step mode where you can advance one tool call at a time. Press Enter to execute each tool.",
|
||||
shortcuts: ["Ctrl+Shift+S"],
|
||||
category: "shortcuts",
|
||||
},
|
||||
];
|
||||
|
||||
export const getTopicsByCategory = (category: HelpCategory): HelpTopic[] =>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* Agent loop that streams LLM responses in real-time to the TUI.
|
||||
* Handles tool call accumulation mid-stream.
|
||||
* Supports pause/resume, step-by-step mode, and abort with rollback.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -20,11 +21,16 @@ import type {
|
||||
PartialToolCall,
|
||||
StreamCallbacks,
|
||||
} from "@/types/streaming";
|
||||
import type { ExecutionControlEvents } from "@/types/execution-control";
|
||||
import { chatStream } from "@providers/core/chat";
|
||||
import { getTool, getToolsForApi, refreshMCPTools } from "@tools/index";
|
||||
import { initializePermissions } from "@services/core/permissions";
|
||||
import { MAX_ITERATIONS, MAX_CONSECUTIVE_ERRORS } from "@constants/agent";
|
||||
import { createStreamAccumulator } from "@/types/streaming";
|
||||
import {
|
||||
createExecutionControl,
|
||||
captureFileState,
|
||||
} from "@services/execution-control";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -36,6 +42,21 @@ interface StreamAgentState {
|
||||
abort: AbortController;
|
||||
options: AgentOptions;
|
||||
callbacks: Partial<StreamCallbacks>;
|
||||
executionControl: ReturnType<typeof createExecutionControl>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended stream callbacks with execution control events
|
||||
*/
|
||||
export interface ExtendedStreamCallbacks extends StreamCallbacks {
|
||||
onPause?: () => void;
|
||||
onResume?: () => void;
|
||||
onStepModeEnabled?: () => void;
|
||||
onStepModeDisabled?: () => void;
|
||||
onWaitingForStep?: (toolName: string, toolArgs: Record<string, unknown>) => void;
|
||||
onAbort?: (rollbackCount: number) => void;
|
||||
onRollback?: (action: { type: string; description: string }) => void;
|
||||
onRollbackComplete?: (actionsRolledBack: number) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -45,14 +66,34 @@ interface StreamAgentState {
|
||||
const createStreamAgentState = (
|
||||
workingDir: string,
|
||||
options: AgentOptions,
|
||||
callbacks: Partial<StreamCallbacks>,
|
||||
): StreamAgentState => ({
|
||||
sessionId: uuidv4(),
|
||||
workingDir,
|
||||
abort: new AbortController(),
|
||||
options,
|
||||
callbacks,
|
||||
});
|
||||
callbacks: Partial<ExtendedStreamCallbacks>,
|
||||
): StreamAgentState => {
|
||||
const extendedCallbacks = callbacks as Partial<ExtendedStreamCallbacks>;
|
||||
|
||||
const executionControlEvents: ExecutionControlEvents = {
|
||||
onPause: extendedCallbacks.onPause,
|
||||
onResume: extendedCallbacks.onResume,
|
||||
onStepModeEnabled: extendedCallbacks.onStepModeEnabled,
|
||||
onStepModeDisabled: extendedCallbacks.onStepModeDisabled,
|
||||
onWaitingForStep: extendedCallbacks.onWaitingForStep,
|
||||
onAbort: extendedCallbacks.onAbort,
|
||||
onRollback: (action) =>
|
||||
extendedCallbacks.onRollback?.({
|
||||
type: action.type,
|
||||
description: action.description,
|
||||
}),
|
||||
onRollbackComplete: extendedCallbacks.onRollbackComplete,
|
||||
};
|
||||
|
||||
return {
|
||||
sessionId: uuidv4(),
|
||||
workingDir,
|
||||
abort: new AbortController(),
|
||||
options,
|
||||
callbacks,
|
||||
executionControl: createExecutionControl(executionControlEvents),
|
||||
};
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Tool Call Accumulation
|
||||
@@ -251,10 +292,46 @@ const finalizeToolCall = (partial: PartialToolCall): ToolCall => {
|
||||
// Tool Execution
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Tools that modify files and support rollback
|
||||
*/
|
||||
const ROLLBACK_CAPABLE_TOOLS: Record<string, "file_write" | "file_edit" | "file_delete" | "bash_command"> = {
|
||||
write: "file_write",
|
||||
edit: "file_edit",
|
||||
delete: "file_delete",
|
||||
bash: "bash_command",
|
||||
};
|
||||
|
||||
const executeTool = async (
|
||||
state: StreamAgentState,
|
||||
toolCall: ToolCall,
|
||||
): Promise<ToolResult> => {
|
||||
// Check if execution was aborted
|
||||
if (state.executionControl.getState() === "aborted") {
|
||||
return {
|
||||
success: false,
|
||||
title: "Aborted",
|
||||
output: "",
|
||||
error: "Execution was aborted",
|
||||
};
|
||||
}
|
||||
|
||||
// Wait if paused
|
||||
await state.executionControl.waitIfPaused();
|
||||
|
||||
// Wait for step confirmation if in step mode
|
||||
await state.executionControl.waitForStep(toolCall.name, toolCall.arguments);
|
||||
|
||||
// Check again after waiting (might have been aborted while waiting)
|
||||
if (state.executionControl.getState() === "aborted") {
|
||||
return {
|
||||
success: false,
|
||||
title: "Aborted",
|
||||
output: "",
|
||||
error: "Execution was aborted",
|
||||
};
|
||||
}
|
||||
|
||||
// Check for debug error markers from truncated/malformed JSON
|
||||
const debugError = toolCall.arguments.__debug_error as string | undefined;
|
||||
if (debugError) {
|
||||
@@ -292,9 +369,32 @@ const executeTool = async (
|
||||
onMetadata: () => {},
|
||||
};
|
||||
|
||||
// Capture file state for rollback if this is a modifying tool
|
||||
const rollbackType = ROLLBACK_CAPABLE_TOOLS[toolCall.name];
|
||||
let originalState: { filePath: string; content: string } | null = null;
|
||||
|
||||
if (rollbackType && (rollbackType === "file_edit" || rollbackType === "file_delete")) {
|
||||
const filePath = toolCall.arguments.file_path as string | undefined;
|
||||
if (filePath) {
|
||||
originalState = await captureFileState(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const validatedArgs = tool.parameters.parse(toolCall.arguments);
|
||||
return await tool.execute(validatedArgs, ctx);
|
||||
const result = await tool.execute(validatedArgs, ctx);
|
||||
|
||||
// Record action for rollback if successful and modifying
|
||||
if (result.success && rollbackType) {
|
||||
const filePath = toolCall.arguments.file_path as string | undefined;
|
||||
state.executionControl.recordAction({
|
||||
type: rollbackType,
|
||||
description: `${toolCall.name}: ${filePath ?? "unknown file"}`,
|
||||
originalState: originalState ?? (filePath ? { filePath, content: "" } : undefined),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const receivedArgs = JSON.stringify(toolCall.arguments);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -496,6 +596,19 @@ export const runAgentLoopStream = async (
|
||||
const agentMessages: AgentMessage[] = [...messages];
|
||||
|
||||
while (iterations < maxIterations) {
|
||||
// Check for abort at start of each iteration
|
||||
if (state.executionControl.getState() === "aborted") {
|
||||
return {
|
||||
success: false,
|
||||
finalResponse: "Execution aborted by user",
|
||||
iterations,
|
||||
toolCalls: allToolCalls,
|
||||
};
|
||||
}
|
||||
|
||||
// Wait if paused
|
||||
await state.executionControl.waitIfPaused();
|
||||
|
||||
iterations++;
|
||||
|
||||
try {
|
||||
@@ -611,7 +724,7 @@ export const runStreamingAgent = async (
|
||||
prompt: string,
|
||||
systemPrompt: string,
|
||||
options: AgentOptions,
|
||||
callbacks: Partial<StreamCallbacks> = {},
|
||||
callbacks: Partial<ExtendedStreamCallbacks> = {},
|
||||
): Promise<AgentResult> => {
|
||||
const messages: Message[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
@@ -623,24 +736,63 @@ export const runStreamingAgent = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a streaming agent instance with stop capability
|
||||
* Streaming agent instance with full execution control
|
||||
*/
|
||||
export interface StreamingAgentInstance {
|
||||
/** Run the agent with given messages */
|
||||
run: (messages: Message[]) => Promise<AgentResult>;
|
||||
/** Stop the agent (abort without rollback) */
|
||||
stop: () => void;
|
||||
/** Update callbacks */
|
||||
updateCallbacks: (newCallbacks: Partial<ExtendedStreamCallbacks>) => void;
|
||||
/** Pause execution */
|
||||
pause: () => void;
|
||||
/** Resume execution */
|
||||
resume: () => void;
|
||||
/** Abort with optional rollback */
|
||||
abort: (rollback?: boolean) => Promise<void>;
|
||||
/** Enable/disable step-by-step mode */
|
||||
stepMode: (enabled: boolean) => void;
|
||||
/** Advance one step in step mode */
|
||||
step: () => void;
|
||||
/** Get current execution state */
|
||||
getExecutionState: () => "running" | "paused" | "stepping" | "aborted" | "completed";
|
||||
/** Check if waiting for step confirmation */
|
||||
isWaitingForStep: () => boolean;
|
||||
/** Get count of rollback actions available */
|
||||
getRollbackCount: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a streaming agent instance with full execution control
|
||||
*/
|
||||
export const createStreamingAgent = (
|
||||
workingDir: string,
|
||||
options: AgentOptions,
|
||||
callbacks: Partial<StreamCallbacks> = {},
|
||||
): {
|
||||
run: (messages: Message[]) => Promise<AgentResult>;
|
||||
stop: () => void;
|
||||
updateCallbacks: (newCallbacks: Partial<StreamCallbacks>) => void;
|
||||
} => {
|
||||
callbacks: Partial<ExtendedStreamCallbacks> = {},
|
||||
): StreamingAgentInstance => {
|
||||
const state = createStreamAgentState(workingDir, options, callbacks);
|
||||
const control = state.executionControl;
|
||||
|
||||
return {
|
||||
run: (messages: Message[]) => runAgentLoopStream(state, messages),
|
||||
stop: () => state.abort.abort(),
|
||||
updateCallbacks: (newCallbacks: Partial<StreamCallbacks>) => {
|
||||
stop: () => {
|
||||
state.abort.abort();
|
||||
control.abort(false);
|
||||
},
|
||||
updateCallbacks: (newCallbacks: Partial<ExtendedStreamCallbacks>) => {
|
||||
Object.assign(state.callbacks, newCallbacks);
|
||||
},
|
||||
pause: () => control.pause(),
|
||||
resume: () => control.resume(),
|
||||
abort: (rollback = false) => control.abort(rollback),
|
||||
stepMode: (enabled: boolean) => control.stepMode(enabled),
|
||||
step: () => control.step(),
|
||||
getExecutionState: () => control.getState(),
|
||||
isWaitingForStep: () => control.isWaitingForStep(),
|
||||
getRollbackCount: () => control.getRollbackActions().length,
|
||||
};
|
||||
};
|
||||
|
||||
// Re-export types for external use
|
||||
export type { ExecutionControl, ExecutionControlEvents } from "@/types/execution-control";
|
||||
|
||||
@@ -27,6 +27,12 @@ export { initializeChatService } from "@services/chat-tui/initialize";
|
||||
export {
|
||||
handleMessage,
|
||||
abortCurrentOperation,
|
||||
pauseCurrentOperation,
|
||||
resumeCurrentOperation,
|
||||
togglePauseResume,
|
||||
setStepMode,
|
||||
advanceStep,
|
||||
getExecutionState,
|
||||
} from "@services/chat-tui/message-handler";
|
||||
|
||||
// Re-export command handling
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
*/
|
||||
|
||||
import { addMessage, saveSession } from "@services/core/session";
|
||||
import { createStreamingAgent } from "@services/agent-stream";
|
||||
import {
|
||||
createStreamingAgent,
|
||||
type StreamingAgentInstance,
|
||||
} from "@services/agent-stream";
|
||||
import { CHAT_MESSAGES } from "@constants/chat-service";
|
||||
import { enrichMessageWithIssues } from "@services/github-issue-service";
|
||||
import { checkGitHubCLI } from "@services/github-pr/cli";
|
||||
@@ -74,26 +77,133 @@ let lastResponseContext: {
|
||||
response: string;
|
||||
} | null = null;
|
||||
|
||||
// Track current running agent for abort capability
|
||||
let currentAgent: { stop: () => void } | null = null;
|
||||
// Track current running agent for execution control
|
||||
let currentAgent: StreamingAgentInstance | null = null;
|
||||
|
||||
/**
|
||||
* Abort the currently running agent operation
|
||||
* @param rollback - If true, attempt to rollback file changes
|
||||
* @returns true if an operation was aborted, false if nothing was running
|
||||
*/
|
||||
export const abortCurrentOperation = (): boolean => {
|
||||
export const abortCurrentOperation = async (
|
||||
rollback = false,
|
||||
): Promise<boolean> => {
|
||||
if (currentAgent) {
|
||||
currentAgent.stop();
|
||||
await currentAgent.abort(rollback);
|
||||
currentAgent = null;
|
||||
appStore.cancelStreaming();
|
||||
appStore.stopThinking();
|
||||
appStore.setMode("idle");
|
||||
addDebugLog("state", "Operation aborted by user");
|
||||
addDebugLog(
|
||||
"state",
|
||||
rollback ? "Operation aborted with rollback" : "Operation aborted by user",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pause the currently running agent operation
|
||||
* @returns true if operation was paused, false if nothing was running
|
||||
*/
|
||||
export const pauseCurrentOperation = (): boolean => {
|
||||
if (currentAgent && currentAgent.getExecutionState() === "running") {
|
||||
currentAgent.pause();
|
||||
appStore.addLog({
|
||||
type: "system",
|
||||
content: "⏸ Execution paused. Press Ctrl+P to resume.",
|
||||
});
|
||||
addDebugLog("state", "Operation paused by user");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resume the currently paused agent operation
|
||||
* @returns true if operation was resumed, false if nothing was paused
|
||||
*/
|
||||
export const resumeCurrentOperation = (): boolean => {
|
||||
if (currentAgent && currentAgent.getExecutionState() === "paused") {
|
||||
currentAgent.resume();
|
||||
appStore.addLog({
|
||||
type: "system",
|
||||
content: "▶ Execution resumed.",
|
||||
});
|
||||
addDebugLog("state", "Operation resumed by user");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle pause/resume for current operation
|
||||
* @returns true if state changed, false if nothing running
|
||||
*/
|
||||
export const togglePauseResume = (): boolean => {
|
||||
if (!currentAgent) return false;
|
||||
|
||||
const state = currentAgent.getExecutionState();
|
||||
if (state === "running" || state === "stepping") {
|
||||
return pauseCurrentOperation();
|
||||
}
|
||||
if (state === "paused") {
|
||||
return resumeCurrentOperation();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable/disable step-by-step mode for current operation
|
||||
* @param enabled - Whether step mode should be enabled
|
||||
* @returns true if mode was changed
|
||||
*/
|
||||
export const setStepMode = (enabled: boolean): boolean => {
|
||||
if (!currentAgent) return false;
|
||||
|
||||
currentAgent.stepMode(enabled);
|
||||
appStore.addLog({
|
||||
type: "system",
|
||||
content: enabled
|
||||
? "🚶 Step mode enabled. Press Enter to advance each tool call."
|
||||
: "🏃 Step mode disabled. Execution will continue automatically.",
|
||||
});
|
||||
addDebugLog("state", `Step mode ${enabled ? "enabled" : "disabled"}`);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Advance one step in step-by-step mode
|
||||
* @returns true if step was advanced
|
||||
*/
|
||||
export const advanceStep = (): boolean => {
|
||||
if (currentAgent && currentAgent.isWaitingForStep()) {
|
||||
currentAgent.step();
|
||||
addDebugLog("state", "Step advanced by user");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current execution state
|
||||
*/
|
||||
export const getExecutionState = (): {
|
||||
state: "idle" | "running" | "paused" | "stepping" | "aborted" | "completed";
|
||||
rollbackCount: number;
|
||||
waitingForStep: boolean;
|
||||
} => {
|
||||
if (!currentAgent) {
|
||||
return { state: "idle", rollbackCount: 0, waitingForStep: false };
|
||||
}
|
||||
return {
|
||||
state: currentAgent.getExecutionState(),
|
||||
rollbackCount: currentAgent.getRollbackCount(),
|
||||
waitingForStep: currentAgent.isWaitingForStep(),
|
||||
};
|
||||
};
|
||||
|
||||
const createToolCallHandler =
|
||||
(
|
||||
callbacks: ChatServiceCallbacks,
|
||||
@@ -574,7 +684,46 @@ export const handleMessage = async (
|
||||
callbacks.onLog("system", warning);
|
||||
},
|
||||
},
|
||||
streamState.callbacks,
|
||||
{
|
||||
...streamState.callbacks,
|
||||
// Execution control callbacks
|
||||
onPause: () => {
|
||||
addDebugLog("state", "Execution paused");
|
||||
},
|
||||
onResume: () => {
|
||||
addDebugLog("state", "Execution resumed");
|
||||
},
|
||||
onStepModeEnabled: () => {
|
||||
addDebugLog("state", "Step mode enabled");
|
||||
},
|
||||
onStepModeDisabled: () => {
|
||||
addDebugLog("state", "Step mode disabled");
|
||||
},
|
||||
onWaitingForStep: (toolName: string, _toolArgs: Record<string, unknown>) => {
|
||||
appStore.addLog({
|
||||
type: "system",
|
||||
content: `⏳ Step mode: Ready to execute ${toolName}. Press Enter to continue.`,
|
||||
});
|
||||
addDebugLog("state", `Waiting for step: ${toolName}`);
|
||||
},
|
||||
onAbort: (rollbackCount: number) => {
|
||||
addDebugLog("state", `Abort initiated, ${rollbackCount} actions to rollback`);
|
||||
},
|
||||
onRollback: (action: { type: string; description: string }) => {
|
||||
appStore.addLog({
|
||||
type: "system",
|
||||
content: `↩ Rolling back: ${action.description}`,
|
||||
});
|
||||
addDebugLog("state", `Rollback: ${action.type} - ${action.description}`);
|
||||
},
|
||||
onRollbackComplete: (actionsRolledBack: number) => {
|
||||
appStore.addLog({
|
||||
type: "system",
|
||||
content: `✓ Rollback complete. ${actionsRolledBack} action(s) undone.`,
|
||||
});
|
||||
addDebugLog("state", `Rollback complete: ${actionsRolledBack} actions`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Store agent reference for abort capability
|
||||
|
||||
245
src/services/execution-control.ts
Normal file
245
src/services/execution-control.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Execution Control Service
|
||||
*
|
||||
* Provides pause/resume, step-by-step mode, and abort with rollback
|
||||
* for agent execution. Based on GitHub issue #113.
|
||||
*/
|
||||
|
||||
import { writeFile, unlink, readFile } from "fs/promises";
|
||||
import type {
|
||||
ExecutionControl,
|
||||
ExecutionControlState,
|
||||
ExecutionControlEvents,
|
||||
ExecutionState,
|
||||
RollbackAction,
|
||||
} from "@/types/execution-control";
|
||||
import { createExecutionControlState } from "@/types/execution-control";
|
||||
|
||||
/**
|
||||
* Create an execution control instance
|
||||
*/
|
||||
export const createExecutionControl = (
|
||||
events: ExecutionControlEvents = {},
|
||||
): ExecutionControl & {
|
||||
/** Internal: record an action for potential rollback */
|
||||
recordAction: (action: Omit<RollbackAction, "id" | "timestamp">) => void;
|
||||
/** Internal: wait for resume if paused */
|
||||
waitIfPaused: () => Promise<void>;
|
||||
/** Internal: wait for step confirmation if in step mode */
|
||||
waitForStep: (toolName: string, toolArgs: Record<string, unknown>) => Promise<void>;
|
||||
/** Internal: get the full state */
|
||||
getFullState: () => ExecutionControlState;
|
||||
} => {
|
||||
const state: ExecutionControlState = createExecutionControlState();
|
||||
|
||||
/**
|
||||
* Wait for resume if paused
|
||||
*/
|
||||
const waitIfPaused = async (): Promise<void> => {
|
||||
if (state.state !== "paused") return;
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
state.resumeResolver = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for step confirmation in step mode
|
||||
*/
|
||||
const waitForStep = async (
|
||||
toolName: string,
|
||||
toolArgs: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
if (!state.stepModeEnabled || state.state === "aborted") return;
|
||||
|
||||
state.waitingForStep = true;
|
||||
events.onWaitingForStep?.(toolName, toolArgs);
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
state.stepResolver = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Record an action for potential rollback
|
||||
*/
|
||||
const recordAction = (
|
||||
action: Omit<RollbackAction, "id" | "timestamp">,
|
||||
): void => {
|
||||
const fullAction: RollbackAction = {
|
||||
...action,
|
||||
id: `action_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
state.rollbackStack.push(fullAction);
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform rollback of recorded actions
|
||||
*/
|
||||
const performRollback = async (): Promise<number> => {
|
||||
let rolledBack = 0;
|
||||
|
||||
// Process in reverse order (LIFO)
|
||||
while (state.rollbackStack.length > 0) {
|
||||
const action = state.rollbackStack.pop();
|
||||
if (!action) continue;
|
||||
|
||||
try {
|
||||
await rollbackAction(action);
|
||||
events.onRollback?.(action);
|
||||
rolledBack++;
|
||||
} catch (error) {
|
||||
// Log but continue with other rollbacks
|
||||
console.error(`Rollback failed for ${action.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
events.onRollbackComplete?.(rolledBack);
|
||||
return rolledBack;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rollback a single action
|
||||
*/
|
||||
const rollbackAction = async (action: RollbackAction): Promise<void> => {
|
||||
const handlers: Record<RollbackAction["type"], () => Promise<void>> = {
|
||||
file_write: async () => {
|
||||
// Delete the created file
|
||||
if (action.originalState?.filePath) {
|
||||
await unlink(action.originalState.filePath).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
file_edit: async () => {
|
||||
// Restore original content
|
||||
if (action.originalState?.filePath && action.originalState?.content) {
|
||||
await writeFile(
|
||||
action.originalState.filePath,
|
||||
action.originalState.content,
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
file_delete: async () => {
|
||||
// Restore deleted file
|
||||
if (action.originalState?.filePath && action.originalState?.content) {
|
||||
await writeFile(
|
||||
action.originalState.filePath,
|
||||
action.originalState.content,
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
bash_command: async () => {
|
||||
// Bash commands cannot be reliably rolled back
|
||||
// Just log the action that was performed
|
||||
},
|
||||
};
|
||||
|
||||
const handler = handlers[action.type];
|
||||
if (handler) {
|
||||
await handler();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
pause: (): void => {
|
||||
if (state.state === "running" || state.state === "stepping") {
|
||||
state.state = "paused";
|
||||
events.onPause?.();
|
||||
}
|
||||
},
|
||||
|
||||
resume: (): void => {
|
||||
if (state.state === "paused") {
|
||||
state.state = state.stepModeEnabled ? "stepping" : "running";
|
||||
events.onResume?.();
|
||||
|
||||
// Resolve any pending pause wait
|
||||
if (state.resumeResolver) {
|
||||
state.resumeResolver();
|
||||
state.resumeResolver = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
abort: async (rollback = false): Promise<void> => {
|
||||
state.state = "aborted";
|
||||
|
||||
// Resolve any pending waits
|
||||
if (state.resumeResolver) {
|
||||
state.resumeResolver();
|
||||
state.resumeResolver = null;
|
||||
}
|
||||
if (state.stepResolver) {
|
||||
state.stepResolver();
|
||||
state.stepResolver = null;
|
||||
}
|
||||
|
||||
const rollbackCount = rollback ? state.rollbackStack.length : 0;
|
||||
events.onAbort?.(rollbackCount);
|
||||
|
||||
if (rollback && state.rollbackStack.length > 0) {
|
||||
await performRollback();
|
||||
}
|
||||
|
||||
state.rollbackStack = [];
|
||||
},
|
||||
|
||||
stepMode: (enabled: boolean): void => {
|
||||
state.stepModeEnabled = enabled;
|
||||
if (enabled) {
|
||||
state.state = "stepping";
|
||||
events.onStepModeEnabled?.();
|
||||
} else {
|
||||
if (state.state === "stepping") {
|
||||
state.state = "running";
|
||||
}
|
||||
events.onStepModeDisabled?.();
|
||||
}
|
||||
},
|
||||
|
||||
step: (): void => {
|
||||
if (state.waitingForStep && state.stepResolver) {
|
||||
state.waitingForStep = false;
|
||||
state.stepResolver();
|
||||
state.stepResolver = null;
|
||||
}
|
||||
},
|
||||
|
||||
getState: (): ExecutionState => state.state,
|
||||
|
||||
getRollbackActions: (): RollbackAction[] => [...state.rollbackStack],
|
||||
|
||||
isWaitingForStep: (): boolean => state.waitingForStep,
|
||||
|
||||
// Internal methods
|
||||
recordAction,
|
||||
waitIfPaused,
|
||||
waitForStep,
|
||||
getFullState: () => ({ ...state }),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to capture file state before modification
|
||||
*/
|
||||
export const captureFileState = async (
|
||||
filePath: string,
|
||||
): Promise<{ filePath: string; content: string } | null> => {
|
||||
try {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
return { filePath, content };
|
||||
} catch {
|
||||
// File doesn't exist, which is fine for new files
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Execution control factory type for dependency injection
|
||||
*/
|
||||
export type ExecutionControlFactory = typeof createExecutionControl;
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
} from "solid-js";
|
||||
import { batch } from "solid-js";
|
||||
import { getFiles } from "@services/file-picker/files";
|
||||
import { abortCurrentOperation } from "@services/chat-tui-service";
|
||||
import {
|
||||
abortCurrentOperation,
|
||||
togglePauseResume,
|
||||
setStepMode,
|
||||
advanceStep,
|
||||
getExecutionState,
|
||||
} from "@services/chat-tui-service";
|
||||
import versionData from "@/version.json";
|
||||
import { ExitProvider, useExit } from "@tui-solid/context/exit";
|
||||
import { RouteProvider, useRoute } from "@tui-solid/context/route";
|
||||
@@ -159,16 +165,74 @@ function AppContent(props: AppProps) {
|
||||
useKeyboard((evt) => {
|
||||
// ESC aborts current operation
|
||||
if (evt.name === "escape") {
|
||||
const aborted = abortCurrentOperation();
|
||||
if (aborted) {
|
||||
toast.info("Operation cancelled");
|
||||
abortCurrentOperation(false).then((aborted) => {
|
||||
if (aborted) {
|
||||
toast.info("Operation cancelled");
|
||||
}
|
||||
});
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+P toggles pause/resume during execution
|
||||
if (evt.ctrl && evt.name === "p") {
|
||||
const toggled = togglePauseResume();
|
||||
if (toggled) {
|
||||
const state = getExecutionState();
|
||||
toast.info(state.state === "paused" ? "⏸ Execution paused" : "▶ Execution resumed");
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+C exits the application
|
||||
// Ctrl+Z aborts with rollback
|
||||
if (evt.ctrl && evt.name === "z") {
|
||||
const state = getExecutionState();
|
||||
if (state.state !== "idle") {
|
||||
abortCurrentOperation(true).then((aborted) => {
|
||||
if (aborted) {
|
||||
toast.info(`Aborted with rollback of ${state.rollbackCount} action(s)`);
|
||||
}
|
||||
});
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Shift+S toggles step mode
|
||||
if (evt.ctrl && evt.shift && evt.name === "s") {
|
||||
const state = getExecutionState();
|
||||
if (state.state !== "idle") {
|
||||
const isStepMode = state.state === "stepping";
|
||||
setStepMode(!isStepMode);
|
||||
toast.info(isStepMode ? "🏃 Step mode disabled" : "🚶 Step mode enabled");
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Enter advances step when waiting for step confirmation
|
||||
if (evt.name === "return" && !evt.ctrl && !evt.shift) {
|
||||
const state = getExecutionState();
|
||||
if (state.waitingForStep) {
|
||||
advanceStep();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+C exits the application (with confirmation)
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
// First try to abort current operation
|
||||
const state = getExecutionState();
|
||||
if (state.state !== "idle") {
|
||||
abortCurrentOperation(false).then(() => {
|
||||
toast.info("Operation cancelled. Press Ctrl+C again to exit.");
|
||||
});
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (app.interruptPending()) {
|
||||
exit.exit(0);
|
||||
evt.preventDefault();
|
||||
|
||||
126
src/types/execution-control.ts
Normal file
126
src/types/execution-control.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Execution Control Types
|
||||
*
|
||||
* Types for controlling agent execution flow.
|
||||
* Supports pause/resume, step-by-step mode, and abort with rollback.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Execution state values
|
||||
*/
|
||||
export type ExecutionState =
|
||||
| "running"
|
||||
| "paused"
|
||||
| "stepping"
|
||||
| "aborted"
|
||||
| "completed";
|
||||
|
||||
/**
|
||||
* Rollback action for undo on abort
|
||||
*/
|
||||
export interface RollbackAction {
|
||||
/** Unique identifier for this action */
|
||||
id: string;
|
||||
/** Type of action to rollback */
|
||||
type: "file_write" | "file_edit" | "file_delete" | "bash_command";
|
||||
/** Description of the action */
|
||||
description: string;
|
||||
/** Original state before the action */
|
||||
originalState?: {
|
||||
filePath?: string;
|
||||
content?: string;
|
||||
};
|
||||
/** Timestamp when action was performed */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution control interface
|
||||
* Provides methods to control agent execution flow
|
||||
*/
|
||||
export interface ExecutionControl {
|
||||
/** Pause the current execution */
|
||||
pause(): void;
|
||||
/** Resume paused execution */
|
||||
resume(): void;
|
||||
/** Abort execution with optional rollback */
|
||||
abort(rollback?: boolean): Promise<void>;
|
||||
/** Enable/disable step-by-step mode */
|
||||
stepMode(enabled: boolean): void;
|
||||
/** Advance one step in step mode */
|
||||
step(): void;
|
||||
/** Get current execution state */
|
||||
getState(): ExecutionState;
|
||||
/** Get pending rollback actions */
|
||||
getRollbackActions(): RollbackAction[];
|
||||
/** Check if execution is waiting for step confirmation */
|
||||
isWaitingForStep(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution control state for internal tracking
|
||||
*/
|
||||
export interface ExecutionControlState {
|
||||
/** Current execution state */
|
||||
state: ExecutionState;
|
||||
/** Whether step-by-step mode is enabled */
|
||||
stepModeEnabled: boolean;
|
||||
/** Whether currently waiting for step confirmation */
|
||||
waitingForStep: boolean;
|
||||
/** Stack of rollback actions for undo on abort */
|
||||
rollbackStack: RollbackAction[];
|
||||
/** Promise resolver for pause/step resume */
|
||||
resumeResolver: (() => void) | null;
|
||||
/** Step resolver for step-by-step mode */
|
||||
stepResolver: (() => void) | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution control events
|
||||
*/
|
||||
export interface ExecutionControlEvents {
|
||||
/** Called when execution is paused */
|
||||
onPause?: () => void;
|
||||
/** Called when execution is resumed */
|
||||
onResume?: () => void;
|
||||
/** Called when entering step mode */
|
||||
onStepModeEnabled?: () => void;
|
||||
/** Called when step mode is disabled */
|
||||
onStepModeDisabled?: () => void;
|
||||
/** Called when waiting for step confirmation */
|
||||
onWaitingForStep?: (toolName: string, toolArgs: Record<string, unknown>) => void;
|
||||
/** Called when abort is initiated */
|
||||
onAbort?: (rollbackCount: number) => void;
|
||||
/** Called when a rollback action is performed */
|
||||
onRollback?: (action: RollbackAction) => void;
|
||||
/** Called when rollback is complete */
|
||||
onRollbackComplete?: (actionsRolledBack: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard shortcuts for execution control
|
||||
*/
|
||||
export const EXECUTION_CONTROL_KEYS = {
|
||||
/** Toggle pause/resume */
|
||||
togglePause: "ctrl+p",
|
||||
/** Toggle step mode */
|
||||
toggleStepMode: "ctrl+s",
|
||||
/** Advance one step in step mode */
|
||||
step: "enter",
|
||||
/** Abort execution */
|
||||
abort: "ctrl+c",
|
||||
/** Abort with rollback */
|
||||
abortWithRollback: "ctrl+z",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default execution control state factory
|
||||
*/
|
||||
export const createExecutionControlState = (): ExecutionControlState => ({
|
||||
state: "running",
|
||||
stepModeEnabled: false,
|
||||
waitingForStep: false,
|
||||
rollbackStack: [],
|
||||
resumeResolver: null,
|
||||
stepResolver: null,
|
||||
});
|
||||
Reference in New Issue
Block a user