/** * Agent Loop State Detection * * This module provides the core logic for detecting the current state of the * Roo Code agent loop. The state is determined by analyzing the clineMessages * array, specifically the last message's type and properties. * * Key insight: The agent loop stops whenever a message with `type: "ask"` arrives, * and the specific `ask` value determines what kind of response the agent is waiting for. */ import { ClineMessage, ClineAsk, isIdleAsk, isResumableAsk, isInteractiveAsk, isNonBlockingAsk } from "@roo-code/types" // ============================================================================= // Agent Loop State Enum // ============================================================================= /** * The possible states of the agent loop. * * State Machine: * ``` * ┌─────────────────┐ * │ NO_TASK │ (initial state) * └────────┬────────┘ * │ newTask * ▼ * ┌─────────────────────────────┐ * ┌───▶│ RUNNING │◀───┐ * │ └──────────┬──────────────────┘ │ * │ │ │ * │ ┌──────────┼──────────────┐ │ * │ │ │ │ │ * │ ▼ ▼ ▼ │ * │ ┌──────┐ ┌─────────┐ ┌──────────┐ │ * │ │STREAM│ │INTERACT │ │ IDLE │ │ * │ │ ING │ │ IVE │ │ │ │ * │ └──┬───┘ └────┬────┘ └────┬─────┘ │ * │ │ │ │ │ * │ │ done │ approved │ newTask │ * └────┴───────────┴────────────┘ │ * │ * ┌──────────────┐ │ * │ RESUMABLE │────────────────────────┘ * └──────────────┘ resumed * ``` */ export enum AgentLoopState { /** * No active task. This is the initial state before any task is started, * or after a task has been cleared. */ NO_TASK = "no_task", /** * Agent is actively processing. This means: * - The last message is a "say" type (informational), OR * - The last message is a non-blocking ask (command_output) * * In this state, the agent may be: * - Executing tools * - Thinking/reasoning * - Processing between API calls */ RUNNING = "running", /** * Agent is streaming a response. This is detected when: * - `partial === true` on the last message, OR * - The last `api_req_started` message has no `cost` in its text field * * Do NOT consider the agent "waiting" while streaming. */ STREAMING = "streaming", /** * Agent is waiting for user approval or input. This includes: * - Tool approvals (file operations) * - Command execution permission * - Browser action permission * - MCP server permission * - Follow-up questions * * User must approve, reject, or provide input to continue. */ WAITING_FOR_INPUT = "waiting_for_input", /** * Task is in an idle/terminal state. This includes: * - Task completed successfully (completion_result) * - API request failed (api_req_failed) * - Too many errors (mistake_limit_reached) * - Auto-approval limit reached * - Completed task waiting to be resumed * * User can start a new task or retry. */ IDLE = "idle", /** * Task is paused and can be resumed. This happens when: * - User navigated away from a task * - Extension was restarted mid-task * * User can resume or abandon the task. */ RESUMABLE = "resumable", } // ============================================================================= // Detailed State Info // ============================================================================= /** * What action the user should/can take in the current state. */ export type RequiredAction = | "none" // No action needed (running/streaming) | "approve" // Can approve/reject (tool, command, mcp) | "answer" // Need to answer a question (followup) | "retry_or_new_task" // Can retry or start new task (api_req_failed) | "proceed_or_new_task" // Can proceed or start new task (mistake_limit) | "start_task" // Should start a new task (completion_result) | "resume_or_abandon" // Can resume or abandon (resume_task) | "start_new_task" // Should start new task (resume_completed_task, no_task) | "continue_or_abort" // Can continue or abort (command_output) /** * Detailed information about the current agent state. * Provides everything needed to render UI or make decisions. */ export interface AgentStateInfo { /** The high-level state of the agent loop */ state: AgentLoopState /** Whether the agent is waiting for user input/action */ isWaitingForInput: boolean /** Whether the agent loop is actively processing */ isRunning: boolean /** Whether content is being streamed */ isStreaming: boolean /** The specific ask type if waiting on an ask, undefined otherwise */ currentAsk?: ClineAsk /** What action the user should/can take */ requiredAction: RequiredAction /** The timestamp of the last message, useful for tracking */ lastMessageTs?: number /** The full last message for advanced usage */ lastMessage?: ClineMessage /** Human-readable description of the current state */ description: string } // ============================================================================= // State Detection Functions // ============================================================================= /** * Structure of the text field in api_req_started messages. * Used to determine if the API request has completed (cost is defined). */ export interface ApiReqStartedText { cost?: number // Undefined while streaming, defined when complete. tokensIn?: number tokensOut?: number cacheWrites?: number cacheReads?: number } /** * Check if an API request is still in progress (streaming). * * API requests are considered in-progress when: * - An api_req_started message exists * - Its text field, when parsed, has `cost: undefined` * * Once the request completes, the cost field will be populated. */ function isApiRequestInProgress(messages: ClineMessage[]): boolean { // Find the last api_req_started message. // Using reverse iteration for efficiency (most recent first). for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if (!message) { continue } if (message.say === "api_req_started") { if (!message.text) { // No text yet means still in progress. return true } try { const data: ApiReqStartedText = JSON.parse(message.text) // cost is undefined while streaming, defined when complete. return data.cost === undefined } catch { // Parse error - assume not in progress. return false } } } return false } /** * Determine the required action based on the current ask type. */ function getRequiredAction(ask: ClineAsk): RequiredAction { switch (ask) { case "followup": return "answer" case "command": case "tool": case "use_mcp_server": return "approve" case "command_output": return "continue_or_abort" case "api_req_failed": return "retry_or_new_task" case "mistake_limit_reached": return "proceed_or_new_task" case "completion_result": return "start_task" case "resume_task": return "resume_or_abandon" case "resume_completed_task": case "auto_approval_max_req_reached": return "start_new_task" default: return "none" } } /** * Get a human-readable description for the current state. */ function getStateDescription(state: AgentLoopState, ask?: ClineAsk): string { switch (state) { case AgentLoopState.NO_TASK: return "No active task. Ready to start a new task." case AgentLoopState.RUNNING: return "Agent is actively processing." case AgentLoopState.STREAMING: return "Agent is streaming a response." case AgentLoopState.WAITING_FOR_INPUT: switch (ask) { case "followup": return "Agent is asking a follow-up question. Please provide an answer." case "command": return "Agent wants to execute a command. Approve or reject." case "tool": return "Agent wants to perform a file operation. Approve or reject." case "use_mcp_server": return "Agent wants to use an MCP server. Approve or reject." default: return "Agent is waiting for user input." } case AgentLoopState.IDLE: switch (ask) { case "completion_result": return "Task completed successfully. You can provide feedback or start a new task." case "api_req_failed": return "API request failed. You can retry or start a new task." case "mistake_limit_reached": return "Too many errors encountered. You can proceed anyway or start a new task." case "auto_approval_max_req_reached": return "Auto-approval limit reached. Manual approval required." case "resume_completed_task": return "Previously completed task. Start a new task to continue." default: return "Task is idle." } case AgentLoopState.RESUMABLE: return "Task is paused. You can resume or start a new task." default: return "Unknown state." } } /** * Detect the current state of the agent loop from the clineMessages array. * * This is the main state detection function. It analyzes the messages array * and returns detailed information about the current agent state. * * @param messages - The clineMessages array from extension state * @returns Detailed state information */ export function detectAgentState(messages: ClineMessage[]): AgentStateInfo { // No messages means no task if (!messages || messages.length === 0) { return { state: AgentLoopState.NO_TASK, isWaitingForInput: false, isRunning: false, isStreaming: false, requiredAction: "start_new_task", description: getStateDescription(AgentLoopState.NO_TASK), } } const lastMessage = messages[messages.length - 1] // Guard against undefined (should never happen after length check, but TypeScript requires it) if (!lastMessage) { return { state: AgentLoopState.NO_TASK, isWaitingForInput: false, isRunning: false, isStreaming: false, requiredAction: "start_new_task", description: getStateDescription(AgentLoopState.NO_TASK), } } // Check if the message is still streaming (partial) // This is the PRIMARY indicator of streaming if (lastMessage.partial === true) { return { state: AgentLoopState.STREAMING, isWaitingForInput: false, isRunning: true, isStreaming: true, currentAsk: lastMessage.ask, requiredAction: "none", lastMessageTs: lastMessage.ts, lastMessage, description: getStateDescription(AgentLoopState.STREAMING), } } // Handle "ask" type messages if (lastMessage.type === "ask" && lastMessage.ask) { const ask = lastMessage.ask // Non-blocking asks (command_output) - agent is running but can be interrupted if (isNonBlockingAsk(ask)) { return { state: AgentLoopState.RUNNING, isWaitingForInput: false, isRunning: true, isStreaming: false, currentAsk: ask, requiredAction: "continue_or_abort", lastMessageTs: lastMessage.ts, lastMessage, description: "Command is running. You can continue or abort.", } } // Idle asks - task has stopped if (isIdleAsk(ask)) { return { state: AgentLoopState.IDLE, isWaitingForInput: true, // User needs to decide what to do next isRunning: false, isStreaming: false, currentAsk: ask, requiredAction: getRequiredAction(ask), lastMessageTs: lastMessage.ts, lastMessage, description: getStateDescription(AgentLoopState.IDLE, ask), } } // Resumable asks - task is paused if (isResumableAsk(ask)) { return { state: AgentLoopState.RESUMABLE, isWaitingForInput: true, isRunning: false, isStreaming: false, currentAsk: ask, requiredAction: getRequiredAction(ask), lastMessageTs: lastMessage.ts, lastMessage, description: getStateDescription(AgentLoopState.RESUMABLE, ask), } } // Interactive asks - waiting for approval/input if (isInteractiveAsk(ask)) { return { state: AgentLoopState.WAITING_FOR_INPUT, isWaitingForInput: true, isRunning: false, isStreaming: false, currentAsk: ask, requiredAction: getRequiredAction(ask), lastMessageTs: lastMessage.ts, lastMessage, description: getStateDescription(AgentLoopState.WAITING_FOR_INPUT, ask), } } } // For "say" type messages, check if API request is in progress if (isApiRequestInProgress(messages)) { return { state: AgentLoopState.STREAMING, isWaitingForInput: false, isRunning: true, isStreaming: true, requiredAction: "none", lastMessageTs: lastMessage.ts, lastMessage, description: getStateDescription(AgentLoopState.STREAMING), } } // Default: agent is running return { state: AgentLoopState.RUNNING, isWaitingForInput: false, isRunning: true, isStreaming: false, requiredAction: "none", lastMessageTs: lastMessage.ts, lastMessage, description: getStateDescription(AgentLoopState.RUNNING), } } /** * Quick check: Is the agent waiting for user input? * * This is a convenience function for simple use cases where you just need * to know if user action is required. */ export function isAgentWaitingForInput(messages: ClineMessage[]): boolean { return detectAgentState(messages).isWaitingForInput } /** * Quick check: Is the agent actively running (not waiting)? */ export function isAgentRunning(messages: ClineMessage[]): boolean { const state = detectAgentState(messages) return state.isRunning && !state.isWaitingForInput } /** * Quick check: Is content currently streaming? */ export function isContentStreaming(messages: ClineMessage[]): boolean { return detectAgentState(messages).isStreaming }