diff --git a/.gitignore b/.gitignore index 48f12a3..3c7950e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -gadget*log +gadget-code.*.log +gadget-drone.*.log logfetch .gadget diff --git a/gadget-code/frontend/src/components/ChatTurn.tsx b/gadget-code/frontend/src/components/ChatTurn.tsx index 9b8af0f..f165ee8 100644 --- a/gadget-code/frontend/src/components/ChatTurn.tsx +++ b/gadget-code/frontend/src/components/ChatTurn.tsx @@ -49,7 +49,9 @@ const ChatTurn = memo(function ChatTurn({ turn }: ChatTurnProps) { ? "text-green-500" : turn.status === "error" ? "text-red-500" - : "text-yellow-500" + : turn.status === "aborted" + ? "text-yellow-500" + : "text-yellow-500" } > {statusLabel} @@ -88,6 +90,17 @@ const ChatTurn = memo(function ChatTurn({ turn }: ChatTurnProps) { )} + {/* Aborted notice */} + {turn.status === "aborted" && ( +
+
+
+ The turn was aborted by you. +
+
+
+ )} + {/* User Prompt */}
diff --git a/gadget-code/frontend/src/lib/api.ts b/gadget-code/frontend/src/lib/api.ts index 5eea22c..9720604 100644 --- a/gadget-code/frontend/src/lib/api.ts +++ b/gadget-code/frontend/src/lib/api.ts @@ -408,7 +408,7 @@ export interface ChatTurn { provider: string | AiProvider; llm: string; mode: ChatSessionMode; - status: "processing" | "finished" | "error"; + status: "processing" | "finished" | "aborted" | "error"; prompts: ChatTurnPrompts; blocks: ChatTurnBlock[]; errorMessage?: string; diff --git a/gadget-code/frontend/src/lib/socket.ts b/gadget-code/frontend/src/lib/socket.ts index 5fa3cd4..444b7d3 100644 --- a/gadget-code/frontend/src/lib/socket.ts +++ b/gadget-code/frontend/src/lib/socket.ts @@ -42,6 +42,7 @@ export interface ServerToClientEvents { export interface ClientToServerEvents { submitPrompt: (content: string) => void; + abortWorkOrder: (cb: (success: boolean, message?: string) => void) => void; requestSessionLock: ( registration: any, project: any, @@ -353,6 +354,16 @@ class SocketClient { } } + abortWorkOrder(cb?: (success: boolean, message?: string) => void): void { + if (this._socket?.connected) { + this._socket.emit('abortWorkOrder', (success: boolean, message?: string) => { + cb?.(success, message); + }); + } else { + cb?.(false, 'Socket not connected'); + } + } + requestSessionLock( registration: any, project: any, diff --git a/gadget-code/frontend/src/pages/ChatSessionView.tsx b/gadget-code/frontend/src/pages/ChatSessionView.tsx index 4765db4..608f1de 100644 --- a/gadget-code/frontend/src/pages/ChatSessionView.tsx +++ b/gadget-code/frontend/src/pages/ChatSessionView.tsx @@ -50,6 +50,7 @@ export default function ChatSessionView() { const [turns, setTurns] = useState([]); const [promptInput, setPromptInput] = useState(''); const [isProcessing, setIsProcessing] = useState(false); + const [isAborting, setIsAborting] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [sessionLocked, setSessionLocked] = useState(true); @@ -81,6 +82,8 @@ export default function ChatSessionView() { const updateRafRef = useRef(null); const currentTurnIdRef = useRef(null); const streamingStateRef = useRef>(new Map()); + const escTimerRef = useRef | null>(null); + const escFlagRef = useRef(false); const subagentStateRef = useRef>(new Map()); const sessionRef = useRef(null); const projectRef = useRef(null); @@ -534,6 +537,16 @@ export default function ChatSessionView() { scheduleUpdate(); }, [mergePendingUpdate, scheduleUpdate]); + const showToast = useCallback((message: string) => { + setToast(message); + if (toastTimerRef.current) { + clearTimeout(toastTimerRef.current); + } + toastTimerRef.current = setTimeout(() => { + setToast(null); + }, 4000); + }, []); + const handleWorkOrderComplete = useCallback((turnId: string, success: boolean, message?: string) => { // Backend has already flushed and persisted all streaming content // Just clean up frontend streaming state and update status @@ -551,19 +564,38 @@ export default function ChatSessionView() { subagentStateRef.current.delete(agentId); } - setTurns(prevTurns => - prevTurns.map(turn => - turn._id === turnId - ? { ...turn, status: success ? 'finished' : 'error', errorMessage: message && !success ? message : turn.errorMessage } - : turn - ) - ); + if (success && message === 'aborted') { + setTurns(prevTurns => + prevTurns.map(turn => + turn._id === turnId + ? { ...turn, status: 'aborted', errorMessage: 'The turn was aborted by you.' } + : turn + ) + ); + showToast('The turn was aborted by you.'); + } else { + setTurns(prevTurns => + prevTurns.map(turn => + turn._id === turnId + ? { ...turn, status: success ? 'finished' : 'error', errorMessage: message && !success ? message : turn.errorMessage } + : turn + ) + ); + if (!success) { + setError(message || 'Work order failed'); + } + } + + // Clean up abort state + if (escTimerRef.current) { + clearTimeout(escTimerRef.current); + escTimerRef.current = null; + } + escFlagRef.current = false; + setIsAborting(false); setIsProcessing(false); currentTurnIdRef.current = null; - if (!success) { - setError(message || 'Work order failed'); - } - }, []); + }, [showToast]); const handleWorkspaceModeChanged = useCallback((mode: string) => { setWorkspaceMode(mode as WorkspaceMode); @@ -712,16 +744,6 @@ export default function ChatSessionView() { }); }, [updateSubagentBlock]); - const showToast = useCallback((message: string) => { - setToast(message); - if (toastTimerRef.current) { - clearTimeout(toastTimerRef.current); - } - toastTimerRef.current = setTimeout(() => { - setToast(null); - }, 4000); - }, []); - const handleWorkspaceModeChange = async (mode: WorkspaceMode) => { if (!session || !project) return; @@ -936,6 +958,64 @@ export default function ChatSessionView() { }); }; + const handleCancel = useCallback(() => { + if (isAborting) return; + setIsAborting(true); + socketClient.abortWorkOrder((success, message) => { + if (success) { + showToast('Aborting Agentic Workflow Loop...'); + } else { + showToast(message || 'Failed to abort'); + setIsAborting(false); + } + }); + }, [isAborting, showToast]); + + // Global Esc key handler for abort: first Esc shows prompt, second Esc within 3s aborts + useEffect(() => { + if (!isProcessing) { + if (escTimerRef.current) { + clearTimeout(escTimerRef.current); + escTimerRef.current = null; + } + escFlagRef.current = false; + return; + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + e.preventDefault(); + + if (!escFlagRef.current) { + escFlagRef.current = true; + showToast('Press Esc again to abort'); + escTimerRef.current = setTimeout(() => { + escFlagRef.current = false; + setToast(null); + escTimerRef.current = null; + }, 3000); + } else { + if (escTimerRef.current) { + clearTimeout(escTimerRef.current); + escTimerRef.current = null; + } + escFlagRef.current = false; + setToast(null); + handleCancel(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + if (escTimerRef.current) { + clearTimeout(escTimerRef.current); + escTimerRef.current = null; + } + escFlagRef.current = false; + }; + }, [isProcessing, showToast, handleCancel]); + const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; @@ -1037,13 +1117,24 @@ export default function ChatSessionView() { rows={3} disabled={promptDisabled} /> - + {isProcessing ? ( + + ) : ( + + )}
diff --git a/gadget-code/src/lib/code-session.ts b/gadget-code/src/lib/code-session.ts index eb96d06..fc00921 100644 --- a/gadget-code/src/lib/code-session.ts +++ b/gadget-code/src/lib/code-session.ts @@ -19,6 +19,7 @@ import { ChatTurnDocument, WorkspaceMode, SubmitPromptCallback, + AbortWorkOrderCallback, } from "@gadget/api"; import ChatSession from "../models/chat-session.ts"; @@ -55,6 +56,7 @@ export class CodeSession extends SocketSession { this.onRequestWorkspaceMode.bind(this), ); this.socket.on("submitPrompt", this.onSubmitPrompt.bind(this)); + this.socket.on("abortWorkOrder", this.onAbortWorkOrder.bind(this)); this.socket.on("releaseSessionLock", this.onReleaseSessionLock.bind(this)); this.socket.on("sessionHeartbeat", this.onSessionHeartbeat.bind(this)); @@ -401,6 +403,23 @@ export class CodeSession extends SocketSession { } } + /** + * Called when the IDE sends an abortWorkOrder event to cancel the + * currently running work order. Forwards to the drone. + */ + onAbortWorkOrder(cb: AbortWorkOrderCallback): void { + if (!this.selectedDrone) { + return cb(false, "No drone selected"); + } + try { + const droneSession = SocketService.getDroneSession(this.selectedDrone); + droneSession.socket.emit("abortWorkOrder", cb); + } catch (error) { + this.log.error("failed to forward abortWorkOrder to drone", { error }); + cb(false, "Failed to reach drone"); + } + } + /** * Called when the IDE sends a releaseSessionLock event to release a * previously-acquired session lock on a gadget-drone instance. diff --git a/gadget-code/src/lib/drone-session.ts b/gadget-code/src/lib/drone-session.ts index bf90285..8b3efe7 100644 --- a/gadget-code/src/lib/drone-session.ts +++ b/gadget-code/src/lib/drone-session.ts @@ -288,9 +288,14 @@ export class DroneSession extends SocketSession { const turn = await ChatTurn.findById(turnId); if (turn) { - turn.status = success ? ChatTurnStatus.Finished : ChatTurnStatus.Error; - if (!success && message) { - turn.errorMessage = message; + if (success && message === "aborted") { + turn.status = ChatTurnStatus.Aborted; + turn.errorMessage = "The turn was aborted by you."; + } else { + turn.status = success ? ChatTurnStatus.Finished : ChatTurnStatus.Error; + if (!success && message) { + turn.errorMessage = message; + } } await turn.save(); } diff --git a/gadget-drone/src/gadget-drone.ts b/gadget-drone/src/gadget-drone.ts index 9dbfc3b..9c59590 100644 --- a/gadget-drone/src/gadget-drone.ts +++ b/gadget-drone/src/gadget-drone.ts @@ -248,6 +248,10 @@ class GadgetDrone extends GadgetProcess { this.onReleaseSessionLock.bind(this), ); this.socket.on("sessionHeartbeat", this.onSessionHeartbeat.bind(this)); + this.socket.on( + "abortWorkOrder", + this.onAbortWorkOrder.bind(this), + ); this.socket.on( "requestTermination", this.onRequestTermination.bind(this), @@ -704,6 +708,16 @@ class GadgetDrone extends GadgetProcess { }); } + async onAbortWorkOrder(cb: (success: boolean, message?: string) => void): Promise { + this.log.info("abortWorkOrder received from platform", { + registrationId: this.registration?._id, + isProcessing: this.isProcessingWorkOrder, + }); + + const aborted = AgentService.abortCurrentWorkOrder(); + cb(aborted, aborted ? "Abort signaled" : "No active work order to abort"); + } + async onRequestTermination(cb: (success: boolean) => void): Promise { this.log.info("requestTermination received from platform", { registrationId: this.registration?._id, diff --git a/gadget-drone/src/services/agent.ts b/gadget-drone/src/services/agent.ts index 4c555ea..6aada0c 100644 --- a/gadget-drone/src/services/agent.ts +++ b/gadget-drone/src/services/agent.ts @@ -79,6 +79,7 @@ class AgentService extends GadgetService { private currentWorkOrder: IAgentWorkOrder | null = null; private currentSocket: DroneSocket | null = null; private currentToolCallId: string | null = null; + private abortController: AbortController | null = null; get name(): string { return "AgentService"; @@ -143,6 +144,7 @@ class AgentService extends GadgetService { ): Promise { this.currentWorkOrder = workOrder; this.currentSocket = socket; + this.abortController = new AbortController(); const { turn } = workOrder; let toolCallCount = 0; @@ -204,6 +206,7 @@ class AgentService extends GadgetService { const chatOptions: IAiChatOptions = { context: messages, tools: this.getToolsForMode(turn.mode), + signal: this.abortController.signal, }; let response: IAiChatResponse; @@ -302,6 +305,11 @@ class AgentService extends GadgetService { socket.emit("workOrderComplete", turn._id, true); } catch (cause) { + if (cause instanceof Error && cause.name === "AbortError") { + this.log.info("work order aborted by user", { turnId: turn._id }); + socket.emit("workOrderComplete", turn._id, true, "aborted"); + return; + } const msg = cause instanceof Error ? cause.message : String(cause); this.log.error("agent loop failed, sending workOrderComplete(false)", { turnId: turn._id, @@ -309,9 +317,21 @@ class AgentService extends GadgetService { }); socket.emit("workOrderComplete", turn._id, false, msg); throw cause; + } finally { + this.abortController = null; } } + /** + * Signals the abort controller for the currently running work order. + * Returns true if an abort was signaled, false if there was no active work order. + */ + abortCurrentWorkOrder(): boolean { + if (!this.abortController) return false; + this.abortController.abort(); + return true; + } + buildSessionContext(workOrder: IAgentWorkOrder): IContextChatMessage[] { const session = workOrder.turn.session as IChatSession; if (!session.user) { @@ -596,6 +616,7 @@ class AgentService extends GadgetService { const chatOptions: IAiChatOptions = { context: messages, tools, + signal: this.abortController?.signal, }; this.log.info("subagent loop iteration", { diff --git a/packages/ai/src/api.ts b/packages/ai/src/api.ts index ecb5981..9123570 100644 --- a/packages/ai/src/api.ts +++ b/packages/ai/src/api.ts @@ -46,6 +46,7 @@ export interface IAiInferenceStats { export interface IAiGenerateOptions { prompt: string; systemPrompt?: string; + signal?: AbortSignal; } export interface IAiGenerateResponse { @@ -89,6 +90,7 @@ export interface IAiChatOptions { userPrompt?: string; context?: IContextChatMessage[]; tools?: IAiTool[]; + signal?: AbortSignal; } export interface IAiChatResponse { diff --git a/packages/ai/src/ollama.ts b/packages/ai/src/ollama.ts index a9af2a6..d1e65f3 100644 --- a/packages/ai/src/ollama.ts +++ b/packages/ai/src/ollama.ts @@ -144,11 +144,16 @@ export class OllamaAiApi extends AiApi { modelId: model.modelId, }); + if (options.signal?.aborted) { + throw new DOMException("The operation was aborted", "AbortError"); + } + const response = await this.client.generate({ model: model.modelId, prompt: options.prompt, system: options.systemPrompt, stream: true, + ...(options.signal ? { signal: options.signal } : {}), options: { num_ctx: model.params.numCtx, num_predict: model.params.numPredict, @@ -161,6 +166,10 @@ export class OllamaAiApi extends AiApi { }; let lastChunk; for await (const chunk of response) { + if (options.signal?.aborted) { + throw new DOMException("The operation was aborted", "AbortError"); + } + lastChunk = chunk; if (chunk.thinking) { @@ -216,6 +225,10 @@ export class OllamaAiApi extends AiApi { modelId: model.modelId, }); + if (options.signal?.aborted) { + throw new DOMException("The operation was aborted", "AbortError"); + } + const messages: OllamaMessage[] = []; if (options.systemPrompt) { @@ -268,6 +281,7 @@ export class OllamaAiApi extends AiApi { stream: true, think: model.params.reasoning, tools: ollamaTools, + ...(options.signal ? { signal: options.signal } : {}), options: { num_ctx: model.params.numCtx, num_predict: model.params.numPredict, @@ -280,6 +294,10 @@ export class OllamaAiApi extends AiApi { const toolCalls: IToolCall[] = []; for await (const chunk of response) { + if (options.signal?.aborted) { + throw new DOMException("The operation was aborted", "AbortError"); + } + lastChunk = chunk; if (chunk.message.thinking) { diff --git a/packages/ai/src/openai.ts b/packages/ai/src/openai.ts index 794f146..594022b 100644 --- a/packages/ai/src/openai.ts +++ b/packages/ai/src/openai.ts @@ -192,6 +192,10 @@ export class OpenAiApi extends AiApi { modelId: model.modelId, }); + if (options.signal?.aborted) { + throw new DOMException("The operation was aborted", "AbortError"); + } + const startTime = Date.now(); const response = await this.client.chat.completions.create({ model: model.modelId, @@ -202,6 +206,7 @@ export class OpenAiApi extends AiApi { { role: "user" as const, content: options.prompt }, ], stream: true, + ...(options.signal ? { signal: options.signal } : {}), ...(model.params.maxCompletionTokens ? { max_completion_tokens: model.params.maxCompletionTokens } : {}), @@ -219,6 +224,10 @@ export class OpenAiApi extends AiApi { let accumulatedThinking = ""; for await (const chunk of response) { + if (options.signal?.aborted) { + throw new DOMException("The operation was aborted", "AbortError"); + } + const delta = chunk.choices[0]?.delta; if (delta) { if (delta.content) { @@ -282,6 +291,7 @@ export class OpenAiApi extends AiApi { messages, tools, streamCallback, + options.signal, ); await this.log.debug("OpenAI chat stream iteration finished", { @@ -295,7 +305,7 @@ export class OpenAiApi extends AiApi { }); if (this.isEmptyIteration(iteration)) { - iteration = await this.readNonStreamingChatCompletion(model, messages, tools); + iteration = await this.readNonStreamingChatCompletion(model, messages, tools, options.signal); if (streamCallback && iteration.response) { await streamCallback({ type: "response", data: iteration.response }); } @@ -370,12 +380,18 @@ export class OpenAiApi extends AiApi { messages: ChatCompletionMessageParam[], tools: ChatCompletionTool[], streamCallback?: IAiResponseStreamFn, + signal?: AbortSignal, ): Promise { + if (signal?.aborted) { + throw new DOMException("The operation was aborted", "AbortError"); + } + const response = await this.client.chat.completions.create({ model: model.modelId, messages, tools, stream: true, + ...(signal ? { signal } : {}), ...(model.params.maxCompletionTokens ? { max_completion_tokens: model.params.maxCompletionTokens } : {}), @@ -398,6 +414,10 @@ export class OpenAiApi extends AiApi { const toolCallMap = new Map(); for await (const chunk of response) { + if (signal?.aborted) { + throw new DOMException("The operation was aborted", "AbortError"); + } + chunkCount++; finishReason = chunk.choices[0]?.finish_reason ?? finishReason; const delta = chunk.choices[0]?.delta; @@ -439,12 +459,14 @@ export class OpenAiApi extends AiApi { model: IAiModelConfig, messages: ChatCompletionMessageParam[], tools: ChatCompletionTool[], + signal?: AbortSignal, ): Promise { const response = await this.client.chat.completions.create({ model: model.modelId, messages, tools, stream: false, + ...(signal ? { signal } : {}), ...(model.params.maxCompletionTokens ? { max_completion_tokens: model.params.maxCompletionTokens } : {}), diff --git a/packages/api/src/messages/ide.ts b/packages/api/src/messages/ide.ts index f216520..07abe80 100644 --- a/packages/api/src/messages/ide.ts +++ b/packages/api/src/messages/ide.ts @@ -80,6 +80,17 @@ export type ReleaseSessionLockMessage = ( cb: ReleaseSessionLockCallback, ) => void; +/* + * abortWorkOrder + */ + +export type AbortWorkOrderCallback = ( + success: boolean, + message?: string, +) => void; + +export type AbortWorkOrderMessage = (cb: AbortWorkOrderCallback) => void; + /* * sessionHeartbeat */ diff --git a/packages/api/src/messages/socket.ts b/packages/api/src/messages/socket.ts index aef7ff9..ab45a90 100644 --- a/packages/api/src/messages/socket.ts +++ b/packages/api/src/messages/socket.ts @@ -17,6 +17,7 @@ import { } from "./drone.ts"; import { SessionUpdatedMessage } from "./web.ts"; import { + AbortWorkOrderMessage, ReleaseSessionLockMessage, RequestSessionLockMessage, RequestWorkspaceModeMessage, @@ -50,6 +51,7 @@ export interface ClientToServerEvents { requestSessionLock: RequestSessionLockMessage; requestWorkspaceMode: RequestWorkspaceModeMessage; submitPrompt: SubmitPromptMessage; + abortWorkOrder: AbortWorkOrderMessage; releaseSessionLock: ReleaseSessionLockMessage; sessionHeartbeat: SessionHeartbeatMessage; @@ -111,6 +113,7 @@ export interface ServerToClientEvents { requestWorkspaceMode: RequestWorkspaceModeMessage; releaseSessionLock: ReleaseSessionLockMessage; sessionHeartbeat: SessionHeartbeatMessage; + abortWorkOrder: AbortWorkOrderMessage; processWorkOrder: ProcessWorkOrderMessage; crashRecoveryResponse: CrashRecoveryResponseMessage; requestTermination: RequestTerminationMessage;