diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index 57a0d2da9e..b1a06de480 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -14,14 +14,17 @@ mod execute; mod preprocess; +mod scheduler; +mod tool_action_model; -use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; use std::sync::Arc; use chrono::Local; pub(crate) use execute::{ - apply_edits, coerce_integer_args, FileReadResult, MalformedFinalLineProxyEvent, + apply_edits, coerce_integer_args, ActionExecution, AgentToolExecutionContext, + AgentToolExecutor, AnyActionExecution, ExecuteActionInput, FileReadResult, + MalformedFinalLineProxyEvent, PreprocessActionInput, SurfaceSpecificToolExecutor, }; #[cfg(test)] pub(crate) use execute::{compose_run_agents_child_prompt, run_agents_to_start_agent_mode}; @@ -36,21 +39,21 @@ pub use execute::{ use futures::future::{join_all, BoxFuture}; use itertools::Itertools; use parking_lot::FairMutex; -use preprocess::{PendingPreprocessedActions, PreprocessId}; +use scheduler::StartedAction; +pub(crate) use scheduler::{AgentToolScheduleHost, AgentToolScheduler}; +pub(crate) use tool_action_model::AgentToolActionModel; use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use self::execute::ask_user_question::AskUserQuestionExecutor; use self::execute::search_codebase::SearchCodebaseExecutor; -use self::execute::{ - BlocklistAIActionExecutor, BlocklistAIActionExecutorEvent, NotExecutedReason, - RunningActionPhase, TryExecuteResult, -}; +use self::execute::{BlocklistAIActionExecutor, BlocklistAIActionExecutorEvent, NotExecutedReason}; +pub(crate) use self::execute::{RunningActionPhase, TryExecuteResult}; use super::BlocklistAIHistoryModel; use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; use crate::ai::agent::{ AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, - AIAgentActionType, AIAgentActionTypeDiscriminants, AIAgentExchange, AIAgentInput, - CancellationReason, CreateDocumentsResult, EditDocumentsResult, RequestCommandOutputResult, + AIAgentActionType, AIAgentActionTypeDiscriminants, AIAgentExchange, CancellationReason, + CreateDocumentsResult, EditDocumentsResult, }; use crate::ai::ai_document_view::DEFAULT_PLANNING_DOCUMENT_TITLE; use crate::ai::blocklist::action_model::execute::suggest_new_conversation::SuggestNewConversationExecutor; @@ -186,51 +189,9 @@ impl RunningActions { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum StartedAction { - Sync, - Async { phase: RunningActionPhase }, -} - -/// Returns whether another action may join the currently running phase. -/// -/// Parallel phases only admit additional actions that classify into the same group and -/// can still be auto-executed. Serial phases always act as a barrier. -fn can_start_action_with_current_phase( - current_phase: RunningActionPhase, - next_phase: RunningActionPhase, - can_autoexecute: bool, -) -> bool { - match current_phase { - RunningActionPhase::Serial => false, - RunningActionPhase::Parallel(group) => { - next_phase == RunningActionPhase::Parallel(group) && can_autoexecute - } - } -} - pub struct BlocklistAIActionModel { executor: ModelHandle, - - pending_preprocessed_actions: HashMap, - - /// Map from conversation ID to queue of pending [`AIAgentAction`]s. - pending_actions: HashMap>, - - /// Map from conversation ID to the currently running action phase, if any. - running_actions: HashMap, - - /// Map from conversation ID to actions received in the most recent AI output that are finished. - finished_action_results: HashMap>>, - - /// Original order for the current batch of actions. - /// - /// We maintain this so that even though we might process actions in parallel, - /// we can still order the results consistently. - action_order: HashMap>, - - /// Past actions and their corresponding statuses from previous AI exchanges. - past_action_results: HashMap>, + tools: AgentToolActionModel, /// The ID of the terminal view this controller is associated with. terminal_view_id: EntityId, @@ -270,9 +231,13 @@ impl BlocklistAIActionModel { result, conversation_id, cancellation_reason, - } => { - me.handle_action_result(*conversation_id, result.clone(), *cancellation_reason, ctx) - } + } => AgentToolScheduler::finish_action( + me, + *conversation_id, + result.clone(), + *cancellation_reason, + ctx, + ), BlocklistAIActionExecutorEvent::InitProject(id) => { ctx.emit(BlocklistAIActionEvent::InitProject(id.clone())) } @@ -295,14 +260,9 @@ impl BlocklistAIActionModel { }); Self { - pending_actions: Default::default(), - finished_action_results: Default::default(), executor, - past_action_results: HashMap::new(), - running_actions: Default::default(), - action_order: Default::default(), + tools: AgentToolActionModel::new(), terminal_view_id, - pending_preprocessed_actions: Default::default(), is_view_only: false, ambient_agent_task_id: None, } @@ -329,11 +289,11 @@ impl BlocklistAIActionModel { // Remove the action from pending_actions for the specific conversation // so that we can correctly show the command as running. - if let Some(pending_actions) = self.pending_actions.get_mut(&conversation_id) { + if let Some(pending_actions) = self.tools.pending_actions.get_mut(&conversation_id) { pending_actions.retain(|a| &a.id != action_id); } - self.add_running_action( + self.tools.record_running_action( conversation_id, action_id.clone(), RunningActionPhase::Serial, @@ -413,104 +373,9 @@ impl BlocklistAIActionModel { }); } - fn blocked_action_for_conversation( - &self, - conversation_id: &AIConversationId, - ) -> Option<&AIAgentAction> { - if self.running_actions.contains_key(conversation_id) { - return None; - } - - self.pending_actions - .get(conversation_id) - .and_then(|queue| queue.front()) - } - - fn action_execution_phase( - &self, - conversation_id: AIConversationId, - ) -> Option { - self.running_actions - .get(&conversation_id) - .map(|running| running.phase) - } - - fn add_running_action( - &mut self, - conversation_id: AIConversationId, - action_id: AIAgentActionId, - phase: RunningActionPhase, - ) { - match self.running_actions.entry(conversation_id) { - std::collections::hash_map::Entry::Occupied(mut entry) => { - debug_assert_eq!(entry.get().phase, phase); - entry.get_mut().add_action(action_id); - } - std::collections::hash_map::Entry::Vacant(entry) => { - entry.insert(RunningActions::new(phase, action_id)); - } - } - } - - fn try_to_execute_available_actions( - &mut self, - conversation_id: AIConversationId, - ctx: &mut ModelContext, - ) { - loop { - let Some(front_action) = self - .pending_actions - .get(&conversation_id) - .and_then(|queue| queue.front()) - .cloned() - else { - return; - }; - - if let Some(current_phase) = self.action_execution_phase(conversation_id) { - if !self.can_start_action_in_current_phase( - &front_action, - conversation_id, - current_phase, - ctx, - ) { - return; - } - } - - let Some(result) = - self.start_pending_action_by_id(&front_action.id, conversation_id, false, ctx) - else { - return; - }; - - if matches!( - result, - StartedAction::Async { - phase: RunningActionPhase::Serial - } - ) { - return; - } - } - } - - fn sort_finished_results(&mut self, conversation_id: AIConversationId) { - if let Some(action_order) = self.action_order.get(&conversation_id) { - if let Some(finished_results) = self.finished_action_results.get_mut(&conversation_id) { - finished_results.sort_by_key(|result| { - action_order.get(&result.id).copied().unwrap_or(usize::MAX) - }); - } - } - } - /// Returns all pending actions for all conversations. pub fn get_pending_actions(&self) -> Vec<&AIAgentAction> { - self.pending_actions - .values() - .flat_map(|queue| queue.iter()) - .collect() + self.tools.get_pending_actions() } /// Returns all pending actions for a specific conversation. @@ -518,24 +383,19 @@ impl BlocklistAIActionModel { &self, conversation_id: &AIConversationId, ) -> impl Iterator { - self.pending_actions - .get(conversation_id) - .into_iter() - .flat_map(|queue| queue.iter()) + self.tools + .get_pending_actions_for_conversation(conversation_id) } /// Returns the next pending action pub fn get_pending_action(&self, app: &AppContext) -> Option<&AIAgentAction> { let conversation_id = self.active_conversation_id(app)?; - self.blocked_action_for_conversation(&conversation_id) + self.tools.blocked_action_for_conversation(&conversation_id) } /// Returns a pending action by its ID, searching across all conversations. pub fn get_pending_action_by_id(&self, action_id: &AIAgentActionId) -> Option<&AIAgentAction> { - self.pending_actions - .values() - .flat_map(|queue| queue.iter()) - .find(|action| &action.id == action_id) + self.tools.get_pending_action_by_id(action_id) } /// Returns the next pending or running action ID, for the active conversation, if any. @@ -544,10 +404,12 @@ impl BlocklistAIActionModel { app: &'a AppContext, ) -> Option<&'a AIAgentActionId> { let conversation_id = self.active_conversation_id(app)?; - self.blocked_action_for_conversation(&conversation_id) + self.tools + .blocked_action_for_conversation(&conversation_id) .map(|action| &action.id) .or_else(|| { - self.running_actions + self.tools + .running_actions .get(&conversation_id) .and_then(RunningActions::first_action_id) }) @@ -563,7 +425,8 @@ impl BlocklistAIActionModel { app: &'a AppContext, ) -> Option<&'a AIAgentAction> { let conversation_id = self.active_conversation_id(app)?; - self.running_actions + self.tools + .running_actions .get(&conversation_id) .and_then(RunningActions::first_action_id) .and_then(|action_id| self.executor.as_ref(app).async_executing_action(action_id)) @@ -574,22 +437,16 @@ impl BlocklistAIActionModel { let Some(conversation_id) = self.active_conversation_id(app) else { return false; }; - self.has_unfinished_actions_for_conversation(conversation_id) + self.tools + .has_unfinished_actions_for_conversation(conversation_id) } pub fn has_unfinished_actions_for_conversation( &self, conversation_id: AIConversationId, ) -> bool { - let has_pending = self - .pending_actions - .get(&conversation_id) - .is_some_and(|queue| !queue.is_empty()); - let has_running = self - .running_actions - .get(&conversation_id) - .is_some_and(|running| !running.is_empty()); - has_pending || has_running + self.tools + .has_unfinished_actions_for_conversation(conversation_id) } /// Returns finished action results received from the most recent AI output for the active conversation. @@ -597,75 +454,21 @@ impl BlocklistAIActionModel { &self, conversation_id: AIConversationId, ) -> Option<&Vec>> { - self.finished_action_results.get(&conversation_id) + self.tools.get_finished_action_results(conversation_id) } /// Returns the `AIActionStatus` for the action corresponding to the given `id`, if any. pub fn get_action_status(&self, id: &AIAgentActionId) -> Option { - for (conversation_id, pending_actions_for_conversation) in &self.pending_actions { - for (index, action) in pending_actions_for_conversation.iter().enumerate() { - if &action.id != id { - continue; - } - - if index == 0 - && !self.is_view_only - && !self.running_actions.contains_key(conversation_id) - { - return Some(AIActionStatus::Blocked); - } - - return Some(AIActionStatus::Queued); - } - } - - self.running_actions - .values() - .find(|running| running.contains(id)) - .map(|_| AIActionStatus::RunningAsync) - .or_else(|| { - self.get_action_result(id) - .map(|result| AIActionStatus::Finished(result.clone())) - }) - .or_else(|| { - self.pending_preprocessed_actions - .values() - .any(|preprocessing| preprocessing.contains(id)) - .then_some(AIActionStatus::Preprocessing) - }) + self.tools.get_action_status(id, self.is_view_only) } pub fn get_action_result(&self, id: &AIAgentActionId) -> Option<&Arc> { - // Search through all conversations' finished action results - self.finished_action_results - .values() - .flat_map(|results| results.iter()) - .find(|result| &result.id == id) - .or_else(|| self.past_action_results.get(id)) + self.tools.get_action_result(id) } /// Bulk restore action results from a list of exchanges (used when loading conversations from tasks) pub fn restore_action_results_from_exchanges(&mut self, exchanges: Vec<&AIAgentExchange>) { - for exchange in exchanges.iter() { - for input in &exchange.input { - if let AIAgentInput::ActionResult { result, .. } = input { - let result_id = result.id.clone(); - let mut result_to_insert = result.clone(); - if let AIAgentActionResultType::RequestCommandOutput( - RequestCommandOutputResult::LongRunningCommandSnapshot { .. }, - ) = &result.result - { - // On restoration we set long running command snapshot results to cancelled, - // since this means the command was incomplete when the app was closed. - result_to_insert.result = AIAgentActionResultType::RequestCommandOutput( - RequestCommandOutputResult::CancelledBeforeExecution, - ); - } - self.past_action_results - .insert(result_id, Arc::new(result_to_insert)); - } - } - } + self.tools.restore_action_results_from_exchanges(exchanges); } /// Dispatches a `RunAgents` action with the user-edited request @@ -677,7 +480,7 @@ impl BlocklistAIActionModel { ctx: &mut ModelContext, ) { let mut found = None; - for (conv_id, queue) in self.pending_actions.iter_mut() { + for (conv_id, queue) in self.tools.pending_actions.iter_mut() { if let Some(action) = queue.iter_mut().find(|action| &action.id == action_id) { found = Some((*conv_id, action)); break; @@ -709,7 +512,7 @@ impl BlocklistAIActionModel { ctx: &mut ModelContext, ) { let mut found: Option<(AIConversationId, AIAgentAction)> = None; - for (conv_id, queue) in self.pending_actions.iter_mut() { + for (conv_id, queue) in self.tools.pending_actions.iter_mut() { if let Some(idx) = queue.iter().position(|a| &a.id == action_id) { if let Some(action) = queue.remove(idx) { found = Some((*conv_id, action)); @@ -730,7 +533,7 @@ impl BlocklistAIActionModel { ai::agent::action_result::RunAgentsResult::Denied { reason }, ), }); - self.handle_action_result(conversation_id, result, None, ctx); + AgentToolScheduler::finish_action(self, conversation_id, result, None, ctx); } /// Attempts to execute the next pending action for the active conversation. @@ -740,6 +543,7 @@ impl BlocklistAIActionModel { ctx: &mut ModelContext, ) { let Some(pending_action_id) = self + .tools .pending_actions .get(&conversation_id) .and_then(|queue| queue.front()) @@ -748,11 +552,16 @@ impl BlocklistAIActionModel { return; }; - if self - .start_pending_action_by_id(&pending_action_id, conversation_id, true, ctx) - .is_some_and(|result| matches!(result, StartedAction::Sync)) + if AgentToolScheduler::start_pending_action_by_id( + self, + &pending_action_id, + conversation_id, + true, + ctx, + ) + .is_some_and(|result| matches!(result, StartedAction::Sync)) { - self.try_to_execute_available_actions(conversation_id, ctx); + AgentToolScheduler::try_to_execute_available_actions(self, conversation_id, ctx); } } @@ -763,11 +572,16 @@ impl BlocklistAIActionModel { conversation_id: AIConversationId, ctx: &mut ModelContext, ) { - if self - .start_pending_action_by_id(action_id, conversation_id, true, ctx) - .is_some_and(|result| matches!(result, StartedAction::Sync)) + if AgentToolScheduler::start_pending_action_by_id( + self, + action_id, + conversation_id, + true, + ctx, + ) + .is_some_and(|result| matches!(result, StartedAction::Sync)) { - self.try_to_execute_available_actions(conversation_id, ctx); + AgentToolScheduler::try_to_execute_available_actions(self, conversation_id, ctx); } } @@ -816,99 +630,6 @@ impl BlocklistAIActionModel { } } - fn action_phase_for_action( - &self, - action: &AIAgentAction, - ctx: &ModelContext, - ) -> RunningActionPhase { - self.executor.as_ref(ctx).action_phase(action, ctx) - } - - fn can_start_action_in_current_phase( - &self, - action: &AIAgentAction, - conversation_id: AIConversationId, - current_phase: RunningActionPhase, - ctx: &mut ModelContext, - ) -> bool { - // Recompute the candidate action's phase on demand so executor-side capability checks - // (for example, whether the active session can run shell commands in parallel) are applied - // using the latest runtime state. - let next_phase = self.action_phase_for_action(action, ctx); - let can_autoexecute = self.executor.update(ctx, |executor, ctx| { - executor.can_autoexecute_action(action, conversation_id, ctx) - }); - can_start_action_with_current_phase(current_phase, next_phase, can_autoexecute) - } - - fn start_pending_action_by_id( - &mut self, - action_id: &AIAgentActionId, - conversation_id: AIConversationId, - is_user_initiated: bool, - ctx: &mut ModelContext, - ) -> Option { - if is_user_initiated && self.running_actions.contains_key(&conversation_id) { - // User-driven approvals still execute one action at a time so that interactive - // confirmations do not overlap in the UI. - return None; - } - - let idx = self - .pending_actions - .get(&conversation_id) - .and_then(|queue| queue.iter().position(|action| &action.id == action_id))?; - - let action = self - .pending_actions - .get_mut(&conversation_id)? - .remove(idx)?; - - let action_id = action.id.clone(); - let phase = self.action_phase_for_action(&action, ctx); - // WaitForEvents owns its own status transition; skip the default - // in-progress update. - let is_wait_for_events = matches!(action.action, AIAgentActionType::WaitForEvents { .. }); - let execute_result = self.executor.update(ctx, |executor, ctx| { - executor.try_to_execute_action(action, conversation_id, is_user_initiated, ctx) - }); - - match execute_result { - TryExecuteResult::ExecutedAsync => { - if !is_wait_for_events { - self.update_conversation_in_progress_status(conversation_id, ctx); - } - self.add_running_action(conversation_id, action_id, phase); - Some(StartedAction::Async { phase }) - } - TryExecuteResult::ExecutedSync => { - if !is_wait_for_events { - self.update_conversation_in_progress_status(conversation_id, ctx); - } - Some(StartedAction::Sync) - } - TryExecuteResult::NotExecuted { reason, action } => { - self.pending_actions - .entry(conversation_id) - .or_default() - .insert(idx, (*action).clone()); - self.handle_not_executed_action(action.as_ref(), reason, conversation_id, ctx); - None - } - } - } - - fn preprocess_action( - &mut self, - action: &AIAgentAction, - conversation_id: AIConversationId, - ctx: &mut ModelContext, - ) -> BoxFuture<'static, ()> { - self.executor.update(ctx, |executor, ctx| { - executor.preprocess_action(action, conversation_id, ctx) - }) - } - /// Queues the `actions` in the given iterator for the given conversation, /// to be dispatched in the order in which they appear in the iterator. pub(super) fn queue_actions( @@ -917,81 +638,7 @@ impl BlocklistAIActionModel { conversation_id: AIConversationId, ctx: &mut ModelContext, ) { - self.action_order.insert( - conversation_id, - actions - .iter() - .enumerate() - .map(|(index, action)| (action.id.clone(), index)) - .collect(), - ); - let mut preprocess_future = Vec::with_capacity(actions.len()); - let mut action_ids = HashSet::with_capacity(actions.len()); - - for action in actions.iter() { - action_ids.insert(action.id.clone()); - preprocess_future.push(self.preprocess_action(action, conversation_id, ctx)); - } - - let preprocess_id = self - .pending_preprocessed_actions - .entry(conversation_id) - .or_default() - .insert_preprocess_action_batch(action_ids); - - ctx.spawn(join_all(preprocess_future), move |me, _, ctx| { - me.handle_preprocess_actions_results(conversation_id, preprocess_id, actions, ctx); - }); - } - - fn handle_preprocess_actions_results( - &mut self, - conversation_id: AIConversationId, - preprocess_id: PreprocessId, - actions: Vec, - ctx: &mut ModelContext, - ) { - let actions_to_enqueue = self - .pending_preprocessed_actions - .entry(conversation_id) - .or_default() - .handle_preprocess_actions_result(preprocess_id, actions); - - for action in actions_to_enqueue { - let action_id = action.id.clone(); - // Some actions may already have results. This can happen in session sharing when - // the sharer finishes and sends a result while preprocessing is still running on the viewer. - // This is an edge case that only happens with fast tool calls, but we still need to guard against it, - // as otherwise tools get stuck in a pending state on the viewer's side of things. This check - // must be scoped to the current conversation as some providers generate tool call IDs that - // only unique within a conversation. - if self - .finished_action_results - .get(&conversation_id) - .is_some_and(|results| results.iter().any(|r| r.id == action_id)) - { - continue; - } - - // In view-only mode, if an action is already marked as running - // (which can happen if we receive a CommandExecutionStarted event - // before the action is queued), don't add it to the pending queue to avoid an inconsistent state. - if self.is_view_only - && self - .running_actions - .get(&conversation_id) - .is_some_and(|running| running.contains(&action_id)) - { - continue; - } - - self.pending_actions - .entry(conversation_id) - .or_default() - .push_back(action); - ctx.emit(BlocklistAIActionEvent::QueuedAction(action_id)); - } - self.try_to_execute_available_actions(conversation_id, ctx); + AgentToolScheduler::queue_actions(self, actions, conversation_id, ctx); } /// Apply a finished action result to the conversation. @@ -1004,7 +651,7 @@ impl BlocklistAIActionModel { ctx: &mut ModelContext, ) { let action_id = action_result.id.clone(); - if let Some(queue) = self.pending_actions.get_mut(&conversation_id) { + if let Some(queue) = self.tools.pending_actions.get_mut(&conversation_id) { if let Some(idx) = queue.iter().position(|a| a.id == action_id) { queue.remove(idx); } @@ -1019,7 +666,13 @@ impl BlocklistAIActionModel { ctx, ); - self.handle_action_result(conversation_id, Arc::new(action_result), None, ctx); + AgentToolScheduler::finish_action( + self, + conversation_id, + Arc::new(action_result), + None, + ctx, + ); } pub(super) fn cancel_action_with_id( @@ -1030,6 +683,7 @@ impl BlocklistAIActionModel { ctx: &mut ModelContext, ) { if self + .tools .running_actions .get(&conversation_id) .is_some_and(|running| running.contains(action_id)) @@ -1039,7 +693,7 @@ impl BlocklistAIActionModel { }); } else { let Some(pending_actions_for_conversation) = - self.pending_actions.get_mut(&conversation_id) + self.tools.pending_actions.get_mut(&conversation_id) else { return; }; @@ -1085,7 +739,7 @@ impl BlocklistAIActionModel { executor.cancel_all_running_async_actions_for_conversation(conversation_id, reason, ctx) }); - let Some(actions_to_cancel) = self.pending_actions.get_mut(&conversation_id) else { + let Some(actions_to_cancel) = self.tools.pending_actions.get_mut(&conversation_id) else { return; }; for action in actions_to_cancel.drain(..).collect_vec() { @@ -1100,34 +754,6 @@ impl BlocklistAIActionModel { } } - /// Removes and returns all pending RequestCommandOutput actions for a conversation. - fn drain_pending_request_command_actions( - &mut self, - conversation_id: AIConversationId, - ) -> Vec { - let Some(pending_actions) = self.pending_actions.get_mut(&conversation_id) else { - return Vec::new(); - }; - - let mut to_drain = Vec::new(); - let mut i = 0; - while i < pending_actions.len() { - if matches!( - pending_actions[i].action, - AIAgentActionType::RequestCommandOutput { .. } - ) { - to_drain.push( - pending_actions - .remove(i) - .expect("index is valid because i < pending_actions.len()"), - ); - } else { - i += 1; - } - } - to_drain - } - fn cancel_pending_action( &mut self, conversation_id: AIConversationId, @@ -1158,7 +784,7 @@ impl BlocklistAIActionModel { task_id: pending_action.task_id, result: pending_action.action.cancelled_result(), }); - self.handle_action_result(conversation_id, result, reason, ctx); + AgentToolScheduler::finish_action(self, conversation_id, result, reason, ctx); } /// Returns all finished action results from the given conversation, moving them to the @@ -1167,26 +793,13 @@ impl BlocklistAIActionModel { &mut self, conversation_id: AIConversationId, ) -> Vec { - self.action_order.remove(&conversation_id); - let finished_action_results = self - .finished_action_results - .remove(&conversation_id) - .unwrap_or_default(); - - for result in finished_action_results.iter() { - self.past_action_results - .insert(result.id.clone(), result.clone()); - } - finished_action_results - .into_iter() - .map(|result| (*result).clone()) - .collect_vec() + self.tools.drain_finished_results(conversation_id) } /// Clears finished action results for a conversation. Used when reverting. pub(super) fn clear_finished_action_results(&mut self, conversation_id: AIConversationId) { - self.action_order.remove(&conversation_id); - self.finished_action_results.remove(&conversation_id); + self.tools.action_order.remove(&conversation_id); + self.tools.finished_action_results.remove(&conversation_id); } /// The control flow for initiating cancellations across suggested plans, requested commands, @@ -1200,7 +813,9 @@ impl BlocklistAIActionModel { ) { // Search through all pending conversations to find the action and conversation ID let mut found_conversation_id = None; - for (conversation_id, pending_actions_for_conversation) in self.pending_actions.iter_mut() { + for (conversation_id, pending_actions_for_conversation) in + self.tools.pending_actions.iter_mut() + { if let Some(action) = pending_actions_for_conversation .iter_mut() .find(|action| action.id == *action_id) @@ -1225,98 +840,6 @@ impl BlocklistAIActionModel { self.execute_action(action_id, conversation_id, ctx); } - fn handle_action_result( - &mut self, - conversation_id: AIConversationId, - action_result: Arc, - cancellation_reason: Option, - ctx: &mut ModelContext, - ) { - let should_remove_entry = - self.running_actions - .get_mut(&conversation_id) - .is_some_and(|running| { - running.remove_action(&action_result.id); - running.is_empty() - }); - - if should_remove_entry { - self.running_actions.remove(&conversation_id); - } - - let action_id = action_result.id.clone(); - - // If a command action entered long-running mode (returned a snapshot), cancel all other - // pending RequestCommandOutput actions. Only one command can be active at a time, and the - // server can only spawn one CLI subagent. We don't cancel other actions because those - // actions will complete before we send any response to the server. NOTE: this does allow - // the long-running command to execute in parallel with the other actions. - if matches!( - &action_result.result, - AIAgentActionResultType::RequestCommandOutput( - RequestCommandOutputResult::LongRunningCommandSnapshot { .. } - ) - ) { - for action in self.drain_pending_request_command_actions(conversation_id) { - self.cancel_pending_action(conversation_id, action, cancellation_reason, ctx); - } - } - - self.finished_action_results - .entry(conversation_id) - .or_default() - .push(action_result); - - ctx.emit(BlocklistAIActionEvent::FinishedAction { - action_id, - conversation_id, - cancellation_reason, - }); - if self - .running_actions - .get(&conversation_id) - .is_some_and(|running| !running.is_empty()) - { - // Wait until the entire phase drains before scheduling subsequent actions or deciding - // whether to send a follow-up request. - return; - } - - // The phase is fully drained — sort results back into original tool-call order. - self.sort_finished_results(conversation_id); - - if self - .pending_actions - .get(&conversation_id) - .is_none_or(|actions| actions.is_empty()) - { - if !cancellation_reason.is_some_and(|r| r.should_preserve_in_progress_status()) { - BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { - let status = if self - .finished_action_results - .get(&conversation_id) - .is_some_and(|finished_results| { - finished_results - .iter() - .all(|result| result.result.is_cancelled()) - }) { - ConversationStatus::Cancelled - } else { - ConversationStatus::InProgress - }; - history_model.update_conversation_status( - self.terminal_view_id, - conversation_id, - status, - ctx, - ); - }); - } - } else { - self.try_to_execute_available_actions(conversation_id, ctx); - } - } - /// In shared-session viewer (view-only) mode, ensure document-related action results /// are backed by documents in the local `AIDocumentModel` and that their /// `DocumentContext` versions match. For CreateDocuments, restore missing documents @@ -1440,6 +963,157 @@ impl Entity for BlocklistAIActionModel { type Event = BlocklistAIActionEvent; } +impl AgentToolScheduleHost for BlocklistAIActionModel { + type Context<'a> = ModelContext<'a, Self>; + + fn app_context<'a, 'b>(ctx: &'a Self::Context<'b>) -> &'a AppContext { + ctx + } + + fn tools(&mut self) -> &mut AgentToolActionModel { + &mut self.tools + } + + fn tools_ref(&self) -> &AgentToolActionModel { + &self.tools + } + + fn preprocess( + &mut self, + action: &AIAgentAction, + conversation_id: AIConversationId, + ctx: &mut Self::Context<'_>, + ) -> BoxFuture<'static, ()> { + self.executor.update(ctx, |e, ctx| { + e.preprocess_action(action, conversation_id, ctx) + }) + } + + fn try_execute( + &mut self, + action: AIAgentAction, + conversation_id: AIConversationId, + is_user_initiated: bool, + ctx: &mut Self::Context<'_>, + ) -> TryExecuteResult { + self.executor.update(ctx, |e, ctx| { + e.try_to_execute_action(action, conversation_id, is_user_initiated, ctx) + }) + } + + fn can_autoexecute( + &mut self, + action: &AIAgentAction, + conversation_id: AIConversationId, + ctx: &mut Self::Context<'_>, + ) -> bool { + self.executor.update(ctx, |e, ctx| { + e.can_autoexecute_action(action, conversation_id, ctx) + }) + } + + fn action_phase(&self, action: &AIAgentAction, ctx: &AppContext) -> RunningActionPhase { + self.executor.as_ref(ctx).action_phase(action, ctx) + } + + fn spawn_after_preprocess( + &mut self, + futures: Vec>, + ctx: &mut Self::Context<'_>, + then: impl FnOnce(&mut Self, &mut Self::Context<'_>) + 'static, + ) { + ctx.spawn(join_all(futures), move |me, _, ctx| then(me, ctx)); + } + + fn should_enqueue( + &self, + conversation_id: AIConversationId, + action_id: &AIAgentActionId, + _ctx: &AppContext, + ) -> bool { + // In view-only mode, skip actions already marked as running (can happen if + // CommandExecutionStarted arrives before the action is queued). + !(self.is_view_only + && self + .tools + .running_actions + .get(&conversation_id) + .is_some_and(|r| r.contains(action_id))) + } + + fn on_action_enqueued( + &mut self, + _conversation_id: AIConversationId, + action_id: &AIAgentActionId, + ctx: &mut Self::Context<'_>, + ) { + ctx.emit(BlocklistAIActionEvent::QueuedAction(action_id.clone())); + } + + fn on_action_started( + &mut self, + conversation_id: AIConversationId, + is_wait_for_events: bool, + ctx: &mut Self::Context<'_>, + ) { + if !is_wait_for_events { + self.update_conversation_in_progress_status(conversation_id, ctx); + } + } + + fn on_action_not_executed( + &mut self, + action: &AIAgentAction, + reason: NotExecutedReason, + conversation_id: AIConversationId, + ctx: &mut Self::Context<'_>, + ) { + self.handle_not_executed_action(action, reason, conversation_id, ctx); + } + + fn on_action_finished( + &mut self, + conversation_id: AIConversationId, + result: &Arc, + cancellation_reason: Option, + ctx: &mut Self::Context<'_>, + ) { + ctx.emit(BlocklistAIActionEvent::FinishedAction { + action_id: result.id.clone(), + conversation_id, + cancellation_reason, + }); + } + + fn on_phase_drained( + &mut self, + conversation_id: AIConversationId, + cancellation_reason: Option, + ctx: &mut Self::Context<'_>, + ) { + if !cancellation_reason.is_some_and(|r| r.should_preserve_in_progress_status()) { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { + let status = if self + .tools + .finished_action_results + .get(&conversation_id) + .is_some_and(|results| results.iter().all(|r| r.result.is_cancelled())) + { + ConversationStatus::Cancelled + } else { + ConversationStatus::InProgress + }; + history_model.update_conversation_status( + self.terminal_view_id, + conversation_id, + status, + ctx, + ); + }); + } + } +} + #[cfg(test)] #[path = "action_model_tests.rs"] mod tests; diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 28982c4a73..ac64f9561e 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -6,7 +6,6 @@ pub(super) mod fetch_conversation; pub(super) mod file_glob; pub(super) mod grep; pub(super) mod read_documents; -pub(super) mod read_files; pub(super) mod read_mcp_resource; pub(super) mod read_skill; pub(super) mod request_computer_use; @@ -26,6 +25,7 @@ use std::any::Any; use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; +use std::time::Duration; use ai::agent::action_result::{InsertReviewCommentsResult, RequestCommandOutputResult}; pub use ask_user_question::AskUserQuestionExecutor; @@ -34,17 +34,14 @@ use call_mcp_tool::CallMCPToolExecutor; use create_documents::CreateDocumentsExecutor; use edit_documents::EditDocumentsExecutor; use fetch_conversation::FetchConversationExecutor; -use file_glob::FileGlobExecutor; use futures::future::BoxFuture; #[cfg(feature = "local_fs")] use futures::AsyncReadExt; use futures::FutureExt; -use grep::GrepExecutor; #[cfg(feature = "local_fs")] use mime_guess::from_path; use parking_lot::FairMutex; use read_documents::ReadDocumentsExecutor; -pub(super) use read_files::ReadFilesExecutor; use read_mcp_resource::ReadMCPResourceExecutor; use read_skill::ReadSkillExecutor; use request_computer_use::RequestComputerUseExecutor; @@ -76,20 +73,22 @@ use warp_files::{FileModel, TextFileReadResult}; use warp_util::file::FileLoadError; #[cfg(feature = "local_fs")] use warp_util::file_type::is_buffer_binary; -use warpui::r#async::{Spawnable, SpawnableOutput}; +use warpui::r#async::{FutureExt as AsyncFutureExt, Spawnable, SpawnableOutput}; use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use self::search_codebase::SearchCodebaseExecutor; use crate::ai::agent::conversation::AIConversationId; +#[cfg(feature = "local_fs")] +use crate::ai::agent::AnyFileContent; use crate::ai::agent::{ AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, AIAgentActionType, AIAgentActionTypeDiscriminants, CancellationReason, FileContext, - FileLocations, ServerOutputId, + FileGlobResult, FileGlobV2Result, FileLocations, GrepResult, ReadFilesResult, ServerOutputId, }; use crate::ai::ambient_agents::AmbientAgentTaskId; +use crate::ai::blocklist::BlocklistAIPermissions; use crate::ai::get_relevant_files::controller::GetRelevantFilesController; -#[cfg(feature = "local_fs")] -use crate::ai::{agent::AnyFileContent, paths::host_native_absolute_path}; +use crate::ai::paths::{host_native_absolute_path, shell_native_absolute_path}; use crate::terminal::model::session::active_session::ActiveSession; use crate::terminal::model::session::command_executor::shell_quote_arg; use crate::terminal::model::session::{ExecuteCommandOptions, Session}; @@ -106,7 +105,7 @@ use crate::BlocklistAIHistoryModel; /// Types of actions that can be executed in parallel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ParallelExecutionPolicy { +pub(crate) enum ParallelExecutionPolicy { /// Read-only actions that only inspect local context and may be safely coalesced into the /// same execution phase when the underlying runtime supports it. ReadOnlyLocalContext, @@ -114,7 +113,7 @@ pub(super) enum ParallelExecutionPolicy { /// Whether an action is running serially or in parallel with other actions. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum RunningActionPhase { +pub(crate) enum RunningActionPhase { /// A barrier action that must run by itself. Serial, /// A phase where several actions from the same compatibility group may be in flight together. @@ -122,21 +121,21 @@ pub(super) enum RunningActionPhase { } #[derive(Debug, Clone, Copy)] -struct ExecuteActionInput<'a> { - action: &'a AIAgentAction, - conversation_id: AIConversationId, +pub(crate) struct ExecuteActionInput<'a> { + pub(crate) action: &'a AIAgentAction, + pub(crate) conversation_id: AIConversationId, } #[derive(Debug, Clone, Copy)] -struct PreprocessActionInput<'a> { - action: &'a AIAgentAction, - conversation_id: AIConversationId, +pub(crate) struct PreprocessActionInput<'a> { + pub(crate) action: &'a AIAgentAction, + pub(crate) conversation_id: AIConversationId, } type AsyncExecuteActionFn = Pin>>; type OnCompleteFn = Box AIAgentActionResultType>; -enum ActionExecution { +pub(crate) enum ActionExecution { Async { execute_future: AsyncExecuteActionFn, on_complete: OnCompleteFn, @@ -147,7 +146,7 @@ enum ActionExecution { } impl ActionExecution { - fn new_async( + pub(crate) fn new_async( execute_future: impl Spawnable, on_complete: impl FnOnce(T, &mut AppContext) -> AIAgentActionResultType + 'static, ) -> Self { @@ -159,13 +158,13 @@ impl ActionExecution { } /// A trait implemented by all types that implement [`Any`] and [`SpawnableOutput`]. -trait AnySpawnableOutput: Any + SpawnableOutput {} +pub(crate) trait AnySpawnableOutput: Any + SpawnableOutput {} impl AnySpawnableOutput for T where T: Any + SpawnableOutput {} type AnyAsyncExecuteActionFn = Pin>>>; type AnyOnCompleteFn = Box, &mut AppContext) -> AIAgentActionResultType>; -enum AnyActionExecution { +pub(crate) enum AnyActionExecution { Async { execute_future: AnyAsyncExecuteActionFn, on_complete: AnyOnCompleteFn, @@ -200,6 +199,453 @@ where } } +const SHARED_GREP_TIMEOUT: Duration = Duration::from_secs(10); +const SHARED_FILE_GLOB_TIMEOUT: Duration = Duration::from_secs(10); + +/// Runtime context needed by surface-neutral tool implementations. +#[derive(Clone)] +pub(crate) struct AgentToolExecutionContext { + pub(crate) current_working_directory: Option, + pub(crate) shell_launch_data: Option, + pub(crate) session: Option>, + pub(crate) terminal_view_id: Option, +} + +/// Surface-specific execution for tool families that cannot be shared. +pub(crate) trait SurfaceSpecificToolExecutor { + type Context<'a>; + + fn tool_execution_context(&self, ctx: &Self::Context<'_>) -> AgentToolExecutionContext; + + fn tool_execution_context_from_app(&self, ctx: &AppContext) -> AgentToolExecutionContext; + + fn app_context<'a, 'b>(ctx: &'a Self::Context<'b>) -> &'a AppContext; + + fn preprocess_shell( + &mut self, + input: PreprocessActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> BoxFuture<'static, ()>; + + fn execute_shell( + &mut self, + input: ExecuteActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> AnyActionExecution; + + fn should_autoexecute_shell( + &mut self, + input: ExecuteActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> bool; + + fn preprocess_file_edits( + &mut self, + input: PreprocessActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> BoxFuture<'static, ()>; + + fn execute_file_edits( + &mut self, + input: ExecuteActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> AnyActionExecution; + + fn should_autoexecute_file_edits( + &mut self, + input: ExecuteActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> bool; + + fn preprocess_other( + &mut self, + _input: PreprocessActionInput<'_>, + _ctx: &mut Self::Context<'_>, + ) -> BoxFuture<'static, ()> { + futures::future::ready(()).boxed() + } + + fn execute_other( + &mut self, + input: ExecuteActionInput<'_>, + _ctx: &mut Self::Context<'_>, + ) -> AnyActionExecution { + AnyActionExecution::Sync(input.action.action.cancelled_result()) + } + + fn should_autoexecute_other( + &mut self, + _input: ExecuteActionInput<'_>, + _ctx: &mut Self::Context<'_>, + ) -> bool { + false + } + + fn action_phase_other(&self, _action: &AIAgentAction, _ctx: &AppContext) -> RunningActionPhase { + RunningActionPhase::Serial + } +} + +/// Shared-first executor for agent tool actions. +pub(crate) struct AgentToolExecutor; + +impl AgentToolExecutor { + /// Routes preprocessing through shared defaults before surface-specific tools. + pub(crate) fn preprocess_action( + surface: &mut S, + input: PreprocessActionInput<'_>, + ctx: &mut S::Context<'_>, + ) -> BoxFuture<'static, ()> + where + S: SurfaceSpecificToolExecutor, + { + match &input.action.action { + AIAgentActionType::RequestCommandOutput { .. } + | AIAgentActionType::WriteToLongRunningShellCommand { .. } + | AIAgentActionType::ReadShellCommandOutput { .. } + | AIAgentActionType::TransferShellCommandControlToUser { .. } => { + surface.preprocess_shell(input, ctx) + } + AIAgentActionType::RequestFileEdits { .. } => surface.preprocess_file_edits(input, ctx), + AIAgentActionType::ReadFiles(..) + | AIAgentActionType::Grep { .. } + | AIAgentActionType::FileGlob { .. } + | AIAgentActionType::FileGlobV2 { .. } => futures::future::ready(()).boxed(), + _ => surface.preprocess_other(input, ctx), + } + } + + /// Routes execution through shared defaults before surface-specific tools. + pub(crate) fn execute_action( + surface: &mut S, + input: ExecuteActionInput<'_>, + ctx: &mut S::Context<'_>, + ) -> AnyActionExecution + where + S: SurfaceSpecificToolExecutor, + { + match &input.action.action { + AIAgentActionType::RequestCommandOutput { .. } + | AIAgentActionType::WriteToLongRunningShellCommand { .. } + | AIAgentActionType::ReadShellCommandOutput { .. } + | AIAgentActionType::TransferShellCommandControlToUser { .. } => { + surface.execute_shell(input, ctx) + } + AIAgentActionType::RequestFileEdits { .. } => surface.execute_file_edits(input, ctx), + AIAgentActionType::ReadFiles(..) => { + Self::execute_read_files(input, surface.tool_execution_context(ctx)) + } + AIAgentActionType::Grep { .. } => { + Self::execute_grep(input, surface.tool_execution_context(ctx)) + } + AIAgentActionType::FileGlob { .. } | AIAgentActionType::FileGlobV2 { .. } => { + Self::execute_file_glob(input, surface.tool_execution_context(ctx)) + } + _ => surface.execute_other(input, ctx), + } + } + + /// Routes auto-execution checks through shared defaults before surface-specific tools. + pub(crate) fn should_autoexecute( + surface: &mut S, + input: ExecuteActionInput<'_>, + ctx: &mut S::Context<'_>, + ) -> bool + where + S: SurfaceSpecificToolExecutor, + { + match &input.action.action { + AIAgentActionType::RequestCommandOutput { .. } + | AIAgentActionType::WriteToLongRunningShellCommand { .. } + | AIAgentActionType::ReadShellCommandOutput { .. } + | AIAgentActionType::TransferShellCommandControlToUser { .. } => { + surface.should_autoexecute_shell(input, ctx) + } + AIAgentActionType::RequestFileEdits { .. } => { + surface.should_autoexecute_file_edits(input, ctx) + } + AIAgentActionType::ReadFiles(..) => Self::can_read_files( + input, + &surface.tool_execution_context(ctx), + S::app_context(ctx), + ), + AIAgentActionType::Grep { .. } => Self::can_grep( + input, + &surface.tool_execution_context(ctx), + S::app_context(ctx), + ), + AIAgentActionType::FileGlob { .. } | AIAgentActionType::FileGlobV2 { .. } => { + Self::can_file_glob( + input, + &surface.tool_execution_context(ctx), + S::app_context(ctx), + ) + } + _ => surface.should_autoexecute_other(input, ctx), + } + } + + /// Computes the shared execution phase when possible. + pub(crate) fn action_phase( + surface: &S, + action: &AIAgentAction, + ctx: &AppContext, + ) -> RunningActionPhase + where + S: SurfaceSpecificToolExecutor, + { + match &action.action { + AIAgentActionType::ReadFiles(..) => { + RunningActionPhase::Parallel(ParallelExecutionPolicy::ReadOnlyLocalContext) + } + AIAgentActionType::Grep { .. } + | AIAgentActionType::FileGlob { .. } + | AIAgentActionType::FileGlobV2 { .. } + if surface + .tool_execution_context_from_app(ctx) + .session + .is_some_and(|session| session.supports_parallel_command_execution()) => + { + RunningActionPhase::Parallel(ParallelExecutionPolicy::ReadOnlyLocalContext) + } + _ => surface.action_phase_other(action, ctx), + } + } + + fn execute_read_files( + input: ExecuteActionInput<'_>, + context: AgentToolExecutionContext, + ) -> AnyActionExecution { + let AIAgentAction { + action: AIAgentActionType::ReadFiles(request), + .. + } = input.action + else { + return ActionExecution::<()>::InvalidAction.into(); + }; + + let locations = request.locations.clone(); + let current_working_directory = context.current_working_directory; + let shell_launch_data = context.shell_launch_data; + + ActionExecution::new_async( + async move { + let result = read_local_file_context( + &locations, + current_working_directory, + shell_launch_data, + None, + None, + ) + .await?; + if result.missing_files.is_empty() { + Ok(ReadFilesResult::Success { + files: result.file_contexts, + }) + } else { + Ok(ReadFilesResult::Error(format!( + "These files do not exist: {}", + result.missing_files.join(", ") + ))) + } + }, + |result: anyhow::Result, _ctx| { + AIAgentActionResultType::ReadFiles( + result.unwrap_or_else(|error| ReadFilesResult::Error(error.to_string())), + ) + }, + ) + .into() + } + + fn execute_grep( + input: ExecuteActionInput<'_>, + context: AgentToolExecutionContext, + ) -> AnyActionExecution { + let AIAgentAction { + action: AIAgentActionType::Grep { queries, path }, + .. + } = input.action + else { + return ActionExecution::<()>::InvalidAction.into(); + }; + + let queries = queries.clone(); + let absolute_path = shell_native_absolute_path( + path, + context.shell_launch_data.as_ref(), + context.current_working_directory.as_ref(), + ); + ActionExecution::new_async( + async move { + match grep::run_grep( + queries, + absolute_path, + context.session, + context.shell_launch_data, + ) + .with_timeout(SHARED_GREP_TIMEOUT) + .await + { + Ok(result) => result, + Err(_) => Err(grep::GrepError::new("Grep operation timed out".to_string())), + } + }, + |result, _ctx| { + AIAgentActionResultType::Grep( + result + .unwrap_or_else(|error| GrepResult::Error(error.error_for_conversation())), + ) + }, + ) + .into() + } + + fn execute_file_glob( + input: ExecuteActionInput<'_>, + context: AgentToolExecutionContext, + ) -> AnyActionExecution { + let AIAgentAction { + action: + AIAgentActionType::FileGlob { patterns, path } + | AIAgentActionType::FileGlobV2 { + patterns, + search_dir: path, + }, + .. + } = input.action + else { + return ActionExecution::<()>::InvalidAction.into(); + }; + + let is_v2 = matches!(input.action.action, AIAgentActionType::FileGlobV2 { .. }); + let patterns = patterns.clone(); + let path = path.clone().unwrap_or_else(|| ".".to_string()); + let absolute_path = shell_native_absolute_path( + &path, + context.shell_launch_data.as_ref(), + context.current_working_directory.as_ref(), + ); + ActionExecution::new_async( + async move { + match file_glob::run_file_glob( + patterns, + absolute_path, + context.session, + context.shell_launch_data, + ) + .with_timeout(SHARED_FILE_GLOB_TIMEOUT) + .await + { + Ok(result) => result, + Err(_) => Err(anyhow::anyhow!("File glob operation timed out")), + } + }, + move |result, _ctx| match result { + Ok(result) if is_v2 => AIAgentActionResultType::FileGlobV2(result), + Ok(result) => AIAgentActionResultType::FileGlob(result.into()), + Err(error) if is_v2 => { + AIAgentActionResultType::FileGlobV2(FileGlobV2Result::Error(error.to_string())) + } + Err(error) => { + AIAgentActionResultType::FileGlob(FileGlobResult::Error(error.to_string())) + } + }, + ) + .into() + } + + fn can_read_files( + input: ExecuteActionInput<'_>, + context: &AgentToolExecutionContext, + ctx: &AppContext, + ) -> bool { + let AIAgentAction { + action: AIAgentActionType::ReadFiles(request), + .. + } = input.action + else { + return false; + }; + BlocklistAIPermissions::as_ref(ctx) + .can_read_files_with_conversation( + &input.conversation_id, + request + .locations + .iter() + .map(|file| { + PathBuf::from(host_native_absolute_path( + &file.name, + &context.shell_launch_data, + &context.current_working_directory, + )) + }) + .collect(), + context.terminal_view_id, + ctx, + ) + .is_allowed() + } + + fn can_grep( + input: ExecuteActionInput<'_>, + context: &AgentToolExecutionContext, + ctx: &AppContext, + ) -> bool { + let AIAgentAction { + action: AIAgentActionType::Grep { path, .. }, + .. + } = input.action + else { + return false; + }; + let absolute_path = host_native_absolute_path( + path, + &context.shell_launch_data, + &context.current_working_directory, + ); + BlocklistAIPermissions::as_ref(ctx) + .can_read_files_with_conversation( + &input.conversation_id, + vec![PathBuf::from(absolute_path)], + context.terminal_view_id, + ctx, + ) + .is_allowed() + } + + fn can_file_glob( + input: ExecuteActionInput<'_>, + context: &AgentToolExecutionContext, + ctx: &AppContext, + ) -> bool { + let AIAgentAction { + action: + AIAgentActionType::FileGlob { path, .. } + | AIAgentActionType::FileGlobV2 { + search_dir: path, .. + }, + .. + } = input.action + else { + return false; + }; + let path = path.clone().unwrap_or_else(|| ".".to_string()); + let absolute_path = host_native_absolute_path( + &path, + &context.shell_launch_data, + &context.current_working_directory, + ); + BlocklistAIPermissions::as_ref(ctx) + .can_read_files_with_conversation( + &input.conversation_id, + vec![PathBuf::from(absolute_path)], + context.terminal_view_id, + ctx, + ) + .is_allowed() + } +} #[derive(Debug, Copy, Clone)] pub enum NotExecutedReason { NotReady, @@ -215,7 +661,7 @@ impl NotExecutedReason { /// Result type for `BlocklistAIActionExecutor::try_to_execute_action`. #[derive(Debug)] -pub(super) enum TryExecuteResult { +pub(crate) enum TryExecuteResult { ExecutedSync, ExecutedAsync, NotExecuted { @@ -244,13 +690,12 @@ impl AsyncExecutingAction { } pub struct BlocklistAIActionExecutor { + active_session: ModelHandle, + terminal_view_id: EntityId, shell_command_executor: ModelHandle, - read_files_executor: ModelHandle, upload_artifact_executor: ModelHandle, search_codebase_executor: ModelHandle, request_file_edits_executor: ModelHandle, - grep_executor: ModelHandle, - file_glob_executor: ModelHandle, read_mcp_resource_executor: ModelHandle, call_mcp_tool_executor: ModelHandle, suggest_new_conversation_executor: ModelHandle, @@ -285,8 +730,6 @@ impl BlocklistAIActionExecutor { terminal_view_id: EntityId, ctx: &mut ModelContext, ) -> Self { - let read_files_executor = - ctx.add_model(|_| ReadFilesExecutor::new(active_session.clone(), terminal_view_id)); let upload_artifact_executor = ctx .add_model(|_| UploadArtifactExecutor::new(active_session.clone(), terminal_view_id)); let search_codebase_executor = ctx.add_model(|ctx| { @@ -309,10 +752,6 @@ impl BlocklistAIActionExecutor { let request_file_edits_executor = ctx.add_model(|ctx| { RequestFileEditsExecutor::new(active_session.clone(), terminal_view_id, ctx) }); - let grep_executor = - ctx.add_model(|_| GrepExecutor::new(active_session.clone(), terminal_view_id)); - let file_glob_executor = - ctx.add_model(|_| FileGlobExecutor::new(active_session.clone(), terminal_view_id)); let read_mcp_resource_executor = ctx .add_model(|_| ReadMCPResourceExecutor::new(active_session.clone(), terminal_view_id)); let call_mcp_tool_executor = @@ -339,12 +778,9 @@ impl BlocklistAIActionExecutor { ctx.add_model(|ctx| WaitForEventsExecutor::new(terminal_view_id, ctx)); Self { shell_command_executor, - read_files_executor, upload_artifact_executor, search_codebase_executor, request_file_edits_executor, - grep_executor, - file_glob_executor, read_mcp_resource_executor, call_mcp_tool_executor, suggest_new_conversation_executor, @@ -363,6 +799,8 @@ impl BlocklistAIActionExecutor { send_message_executor, ask_user_question_executor, wait_for_events_executor, + active_session, + terminal_view_id, } } @@ -426,27 +864,7 @@ impl BlocklistAIActionExecutor { } pub fn action_phase(&self, action: &AIAgentAction, ctx: &AppContext) -> RunningActionPhase { - match &action.action { - AIAgentActionType::ReadFiles(..) - | AIAgentActionType::SearchCodebase(..) - | AIAgentActionType::ReadSkill(_) => { - RunningActionPhase::Parallel(ParallelExecutionPolicy::ReadOnlyLocalContext) - } - AIAgentActionType::Grep { .. } - if self.grep_executor.as_ref(ctx).can_execute_in_parallel(ctx) => - { - RunningActionPhase::Parallel(ParallelExecutionPolicy::ReadOnlyLocalContext) - } - AIAgentActionType::FileGlob { .. } | AIAgentActionType::FileGlobV2 { .. } - if self - .file_glob_executor - .as_ref(ctx) - .can_execute_in_parallel(ctx) => - { - RunningActionPhase::Parallel(ParallelExecutionPolicy::ReadOnlyLocalContext) - } - _ => RunningActionPhase::Serial, - } + AgentToolExecutor::action_phase(self, action, ctx) } pub fn ask_user_question_executor(&self) -> &ModelHandle { @@ -468,7 +886,7 @@ impl BlocklistAIActionExecutor { } pub fn preprocess_action( - &self, + &mut self, action: &AIAgentAction, conversation_id: AIConversationId, ctx: &mut ModelContext, @@ -483,87 +901,7 @@ impl BlocklistAIActionExecutor { conversation_id, }; - match &action.action { - AIAgentActionType::RequestCommandOutput { .. } - | AIAgentActionType::WriteToLongRunningShellCommand { .. } - | AIAgentActionType::ReadShellCommandOutput { .. } - | AIAgentActionType::TransferShellCommandControlToUser { .. } => self - .shell_command_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::ReadFiles(..) => self - .read_files_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::UploadArtifact(..) => self - .upload_artifact_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::SearchCodebase(..) => self - .search_codebase_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::Grep { .. } => self - .grep_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::FileGlob { .. } | AIAgentActionType::FileGlobV2 { .. } => self - .file_glob_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::CallMCPTool { .. } => self - .call_mcp_tool_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::ReadMCPResource { .. } => self - .read_mcp_resource_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - // Normally, requested file edits are not handled by the executor. However, when performing a task autonomously, - // the executor is responsible for auto-approving diffs. - AIAgentActionType::RequestFileEdits { .. } => self - .request_file_edits_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::InitProject => futures::future::ready(()).boxed(), - AIAgentActionType::OpenCodeReview => futures::future::ready(()).boxed(), - AIAgentActionType::InsertCodeReviewComments { .. } => { - futures::future::ready(()).boxed() - } - AIAgentActionType::SuggestNewConversation { .. } => self - .suggest_new_conversation_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::SuggestPrompt { .. } => self - .suggest_prompt_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::ReadDocuments(_) => self - .read_documents_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::EditDocuments(_) => self - .edit_documents_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::CreateDocuments(_) => self - .create_documents_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::UseComputer(_) => self - .use_computer_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::RequestComputerUse(_) => self - .request_computer_use_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::ReadSkill(_) => self - .read_skill_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::FetchConversation { .. } => self - .fetch_conversation_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::StartAgent { .. } => self - .start_agent_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::SendMessageToAgent { .. } => self - .send_message_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::AskUserQuestion { .. } => self - .ask_user_question_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::RunAgents(_) => self - .run_agents_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - AIAgentActionType::WaitForEvents { .. } => self - .wait_for_events_executor - .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), - } + AgentToolExecutor::preprocess_action(self, input, ctx) } /// Returns `None` if the action was executed (and thereby consumed). @@ -630,145 +968,19 @@ impl BlocklistAIActionExecutor { } let action_clone = action.clone(); - let execution = match &action.action { - AIAgentActionType::RequestCommandOutput { .. } - | AIAgentActionType::WriteToLongRunningShellCommand { .. } - | AIAgentActionType::ReadShellCommandOutput { .. } - | AIAgentActionType::TransferShellCommandControlToUser { .. } => self - .shell_command_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::InitProject => { - ctx.emit(BlocklistAIActionExecutorEvent::InitProject(action.id)); - ActionExecution::<()>::Sync(AIAgentActionResultType::InitProject).into() - } - AIAgentActionType::OpenCodeReview => { - ctx.emit(BlocklistAIActionExecutorEvent::OpenCodeReview(action.id)); - ActionExecution::<()>::Sync(AIAgentActionResultType::OpenCodeReview).into() - } - AIAgentActionType::InsertCodeReviewComments { - repo_path, - comments, - base_branch, - } => { - if FeatureFlag::PRCommentsSlashCommand.is_enabled() { - ctx.emit(BlocklistAIActionExecutorEvent::InsertCodeReviewComments { - action_id: action.id, - repo_path: repo_path.clone(), - comments: comments.clone(), - base_branch: base_branch.clone(), - }); - } - ActionExecution::<()>::Sync(AIAgentActionResultType::InsertReviewComments( - InsertReviewCommentsResult::Success { - repo_path: repo_path.to_string_lossy().to_string(), - }, - )) - .into() - } - AIAgentActionType::ReadFiles(..) => self - .read_files_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::UploadArtifact(..) => self - .upload_artifact_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)), - AIAgentActionType::SearchCodebase(..) => self - .search_codebase_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::Grep { .. } => self - .grep_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::FileGlob { .. } | AIAgentActionType::FileGlobV2 { .. } => self - .file_glob_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::CallMCPTool { .. } => self - .call_mcp_tool_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::ReadMCPResource { .. } => self - .read_mcp_resource_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - // Normally, requested file edits are not handled by the executor. However, when performing a task autonomously, - // the executor is responsible for auto-approving diffs. - AIAgentActionType::RequestFileEdits { .. } => self - .request_file_edits_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::SuggestNewConversation { .. } => self - .suggest_new_conversation_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::SuggestPrompt { .. } => self - .suggest_prompt_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::ReadDocuments(_) => self - .read_documents_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::EditDocuments(_) => self - .edit_documents_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::CreateDocuments(_) => self - .create_documents_executor - .update(ctx, |executor, ctx| { - executor.execute(input, conversation_id, ctx) - }) - .into(), - AIAgentActionType::UseComputer(_) => self - .use_computer_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::RequestComputerUse(_) => self - .request_computer_use_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::ReadSkill(_) => self - .read_skill_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::FetchConversation { .. } => self - .fetch_conversation_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::StartAgent { .. } => self - .start_agent_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::SendMessageToAgent { .. } => self - .send_message_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)), - AIAgentActionType::AskUserQuestion { .. } => self - .ask_user_question_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::RunAgents(_) => self - .run_agents_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - AIAgentActionType::WaitForEvents { .. } => self - .wait_for_events_executor - .update(ctx, |executor, ctx| executor.execute(input, ctx)) - .into(), - }; - - let action_id = action_clone.id.clone(); - match execution { - AnyActionExecution::NotReady => TryExecuteResult::NotExecuted { - reason: NotExecutedReason::NotReady, - action: Box::new(action_clone), - }, - AnyActionExecution::InvalidAction => { - debug_assert!(false, "Tried to execute AIAgentAction with wrong executor."); - TryExecuteResult::NotExecuted { - reason: NotExecutedReason::NotReady, - action: Box::new(action_clone), + let execution = AgentToolExecutor::execute_action(self, input, ctx); + + let action_id = action_clone.id.clone(); + match execution { + AnyActionExecution::NotReady => TryExecuteResult::NotExecuted { + reason: NotExecutedReason::NotReady, + action: Box::new(action_clone), + }, + AnyActionExecution::InvalidAction => { + debug_assert!(false, "Tried to execute AIAgentAction with wrong executor."); + TryExecuteResult::NotExecuted { + reason: NotExecutedReason::NotReady, + action: Box::new(action_clone), } } AnyActionExecution::Async { @@ -821,7 +1033,7 @@ impl BlocklistAIActionExecutor { } pub fn can_autoexecute_action( - &self, + &mut self, action: &AIAgentAction, conversation_id: AIConversationId, ctx: &mut ModelContext, @@ -903,32 +1115,292 @@ impl BlocklistAIActionExecutor { } } - fn should_autoexecute(&self, input: ExecuteActionInput, ctx: &mut ModelContext) -> bool { + fn should_autoexecute( + &mut self, + input: ExecuteActionInput, + ctx: &mut ModelContext, + ) -> bool { + AgentToolExecutor::should_autoexecute(self, input, ctx) + } + + fn is_shared_session_viewer(&self) -> bool { + self.terminal_model.lock().is_shared_session_viewer() + } +} + +impl SurfaceSpecificToolExecutor for BlocklistAIActionExecutor { + type Context<'a> = ModelContext<'a, Self>; + + fn tool_execution_context(&self, ctx: &Self::Context<'_>) -> AgentToolExecutionContext { + let active_session = self.active_session.as_ref(ctx); + AgentToolExecutionContext { + current_working_directory: active_session.current_working_directory().cloned(), + shell_launch_data: active_session.shell_launch_data(ctx), + session: active_session.session(ctx), + terminal_view_id: Some(self.terminal_view_id), + } + } + + fn tool_execution_context_from_app(&self, ctx: &AppContext) -> AgentToolExecutionContext { + let active_session = self.active_session.as_ref(ctx); + AgentToolExecutionContext { + current_working_directory: active_session.current_working_directory().cloned(), + shell_launch_data: active_session.shell_launch_data(ctx), + session: active_session.session(ctx), + terminal_view_id: Some(self.terminal_view_id), + } + } + + fn app_context<'a, 'b>(ctx: &'a Self::Context<'b>) -> &'a AppContext { + ctx + } + + fn preprocess_shell( + &mut self, + input: PreprocessActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> BoxFuture<'static, ()> { + self.shell_command_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)) + } + + fn execute_shell( + &mut self, + input: ExecuteActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> AnyActionExecution { + self.shell_command_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into() + } + + fn should_autoexecute_shell( + &mut self, + input: ExecuteActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> bool { + self.shell_command_executor + .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)) + } + + fn preprocess_file_edits( + &mut self, + input: PreprocessActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> BoxFuture<'static, ()> { + self.request_file_edits_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)) + } + + fn execute_file_edits( + &mut self, + input: ExecuteActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> AnyActionExecution { + self.request_file_edits_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into() + } + + fn should_autoexecute_file_edits( + &mut self, + input: ExecuteActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> bool { + self.request_file_edits_executor + .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)) + } + + fn preprocess_other( + &mut self, + input: PreprocessActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> BoxFuture<'static, ()> { + match &input.action.action { + AIAgentActionType::UploadArtifact(..) => self + .upload_artifact_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::SearchCodebase(..) => self + .search_codebase_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::CallMCPTool { .. } => self + .call_mcp_tool_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::ReadMCPResource { .. } => self + .read_mcp_resource_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::SuggestNewConversation { .. } => self + .suggest_new_conversation_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::SuggestPrompt { .. } => self + .suggest_prompt_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::ReadDocuments(_) => self + .read_documents_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::EditDocuments(_) => self + .edit_documents_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::CreateDocuments(_) => self + .create_documents_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::UseComputer(_) => self + .use_computer_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::RequestComputerUse(_) => self + .request_computer_use_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::ReadSkill(_) => self + .read_skill_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::FetchConversation { .. } => self + .fetch_conversation_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::StartAgent { .. } => self + .start_agent_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::SendMessageToAgent { .. } => self + .send_message_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::AskUserQuestion { .. } => self + .ask_user_question_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::RunAgents(_) => self + .run_agents_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + AIAgentActionType::WaitForEvents { .. } => self + .wait_for_events_executor + .update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)), + _ => futures::future::ready(()).boxed(), + } + } + + fn execute_other( + &mut self, + input: ExecuteActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> AnyActionExecution { + match &input.action.action { + AIAgentActionType::InitProject => { + ctx.emit(BlocklistAIActionExecutorEvent::InitProject( + input.action.id.clone(), + )); + ActionExecution::<()>::Sync(AIAgentActionResultType::InitProject).into() + } + AIAgentActionType::OpenCodeReview => { + ctx.emit(BlocklistAIActionExecutorEvent::OpenCodeReview( + input.action.id.clone(), + )); + ActionExecution::<()>::Sync(AIAgentActionResultType::OpenCodeReview).into() + } + AIAgentActionType::InsertCodeReviewComments { + repo_path, + comments, + base_branch, + } => { + if FeatureFlag::PRCommentsSlashCommand.is_enabled() { + ctx.emit(BlocklistAIActionExecutorEvent::InsertCodeReviewComments { + action_id: input.action.id.clone(), + repo_path: repo_path.clone(), + comments: comments.clone(), + base_branch: base_branch.clone(), + }); + } + ActionExecution::<()>::Sync(AIAgentActionResultType::InsertReviewComments( + InsertReviewCommentsResult::Success { + repo_path: repo_path.to_string_lossy().to_string(), + }, + )) + .into() + } + AIAgentActionType::UploadArtifact(..) => self + .upload_artifact_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)), + AIAgentActionType::SearchCodebase(..) => self + .search_codebase_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + AIAgentActionType::CallMCPTool { .. } => self + .call_mcp_tool_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + AIAgentActionType::ReadMCPResource { .. } => self + .read_mcp_resource_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + AIAgentActionType::SuggestNewConversation { .. } => self + .suggest_new_conversation_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + AIAgentActionType::SuggestPrompt { .. } => self + .suggest_prompt_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + AIAgentActionType::ReadDocuments(_) => self + .read_documents_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + AIAgentActionType::EditDocuments(_) => self + .edit_documents_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + AIAgentActionType::CreateDocuments(_) => self + .create_documents_executor + .update(ctx, |executor, ctx| { + executor.execute(input, input.conversation_id, ctx) + }) + .into(), + AIAgentActionType::UseComputer(_) => self + .use_computer_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + AIAgentActionType::RequestComputerUse(_) => self + .request_computer_use_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + AIAgentActionType::ReadSkill(_) => self + .read_skill_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + AIAgentActionType::FetchConversation { .. } => self + .fetch_conversation_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + AIAgentActionType::StartAgent { .. } => self + .start_agent_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + AIAgentActionType::SendMessageToAgent { .. } => self + .send_message_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)), + AIAgentActionType::AskUserQuestion { .. } => self + .ask_user_question_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + AIAgentActionType::RunAgents(_) => self + .run_agents_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + AIAgentActionType::WaitForEvents { .. } => self + .wait_for_events_executor + .update(ctx, |executor, ctx| executor.execute(input, ctx)) + .into(), + _ => AnyActionExecution::Sync(input.action.action.cancelled_result()), + } + } + + fn should_autoexecute_other( + &mut self, + input: ExecuteActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> bool { match input.action.action { - AIAgentActionType::RequestCommandOutput { .. } - | AIAgentActionType::WriteToLongRunningShellCommand { .. } - | AIAgentActionType::ReadShellCommandOutput { .. } - | AIAgentActionType::TransferShellCommandControlToUser { .. } => self - .shell_command_executor - .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), - AIAgentActionType::ReadFiles(_) => self - .read_files_executor - .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::UploadArtifact(_) => self .upload_artifact_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::SearchCodebase(_) => self .search_codebase_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), - AIAgentActionType::RequestFileEdits { .. } => self - .request_file_edits_executor - .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), - AIAgentActionType::Grep { .. } => self - .grep_executor - .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), - AIAgentActionType::FileGlob { .. } | AIAgentActionType::FileGlobV2 { .. } => self - .file_glob_executor - .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::CallMCPTool { .. } => self .call_mcp_tool_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), @@ -980,11 +1452,17 @@ impl BlocklistAIActionExecutor { AIAgentActionType::WaitForEvents { .. } => self .wait_for_events_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), + _ => false, } } - fn is_shared_session_viewer(&self) -> bool { - self.terminal_model.lock().is_shared_session_viewer() + fn action_phase_other(&self, action: &AIAgentAction, _ctx: &AppContext) -> RunningActionPhase { + match &action.action { + AIAgentActionType::SearchCodebase(..) | AIAgentActionType::ReadSkill(_) => { + RunningActionPhase::Parallel(ParallelExecutionPolicy::ReadOnlyLocalContext) + } + _ => RunningActionPhase::Serial, + } } } impl Entity for BlocklistAIActionExecutor { diff --git a/app/src/ai/blocklist/action_model/execute/file_glob.rs b/app/src/ai/blocklist/action_model/execute/file_glob.rs index 9eb3e97976..20dbb0b212 100644 --- a/app/src/ai/blocklist/action_model/execute/file_glob.rs +++ b/app/src/ai/blocklist/action_model/execute/file_glob.rs @@ -1,200 +1,17 @@ -use std::path::PathBuf; use std::sync::Arc; -use std::time::Duration; -use futures::future::BoxFuture; -use futures::FutureExt; use itertools::Itertools; use warp_core::features::FeatureFlag; -use warpui::r#async::FutureExt as AsyncFutureExt; -use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; -use crate::ai::agent::conversation::AIConversationId; -use crate::ai::agent::{ - AIAgentAction, AIAgentActionResultType, AIAgentActionType, FileGlobResult, FileGlobV2Match, - FileGlobV2Result, -}; -use crate::ai::blocklist::BlocklistAIPermissions; -use crate::ai::paths::{host_native_absolute_path, join_paths, shell_native_absolute_path}; -use crate::terminal::model::session::active_session::ActiveSession; +use super::is_git_repository; +use crate::ai::agent::{FileGlobV2Match, FileGlobV2Result}; +use crate::ai::paths::join_paths; use crate::terminal::model::session::command_executor::shell_quote_arg; use crate::terminal::model::session::{ExecuteCommandOptions, Session}; use crate::terminal::shell::ShellType; use crate::terminal::ShellLaunchData; -use crate::{send_telemetry_from_app_ctx, TelemetryEvent}; -const FILE_GLOB_TIMEOUT: Duration = Duration::from_secs(10); - -use super::{ - get_server_output_id, is_git_repository, ActionExecution, AnyActionExecution, - ExecuteActionInput, PreprocessActionInput, -}; - -pub struct FileGlobExecutor { - active_session: ModelHandle, - terminal_view_id: EntityId, -} - -fn log_file_glob_error(conversation_id: AIConversationId, ctx: &mut AppContext) { - let server_output_id = get_server_output_id(conversation_id, ctx); - send_telemetry_from_app_ctx!(TelemetryEvent::FileGlobToolFailed { server_output_id }, ctx); -} - -impl FileGlobExecutor { - pub fn new(active_session: ModelHandle, terminal_view_id: EntityId) -> Self { - Self { - active_session, - terminal_view_id, - } - } - - pub(super) fn should_autoexecute( - &self, - input: ExecuteActionInput, - ctx: &mut ModelContext, - ) -> bool { - let ExecuteActionInput { - action: - AIAgentAction { - action: - AIAgentActionType::FileGlob { path, .. } - | AIAgentActionType::FileGlobV2 { - search_dir: path, .. - }, - .. - }, - conversation_id, - } = input - else { - return false; - }; - - // If the path is not provided, use the current working directory. - let path = path.clone().unwrap_or_else(|| ".".to_string()); - - let current_working_directory = self - .active_session - .as_ref(ctx) - .current_working_directory() - .cloned(); - let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx); - let absolute_path = - host_native_absolute_path(path.as_str(), &shell, ¤t_working_directory); - - BlocklistAIPermissions::as_ref(ctx) - .can_read_files_with_conversation( - &conversation_id, - vec![PathBuf::from(absolute_path)], - Some(self.terminal_view_id), - ctx, - ) - .is_allowed() - } - - pub(super) fn execute( - &mut self, - input: ExecuteActionInput, - ctx: &mut ModelContext, - ) -> impl Into { - let AIAgentAction { - action: - AIAgentActionType::FileGlob { patterns, path } - | AIAgentActionType::FileGlobV2 { - patterns, - search_dir: path, - }, - .. - } = input.action - else { - return ActionExecution::InvalidAction; - }; - - // If the path is not provided, use the current working directory. - let path = path.clone().unwrap_or_else(|| ".".to_string()); - - let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx); - let current_working_directory = self - .active_session - .as_ref(ctx) - .current_working_directory() - .cloned(); - let absolute_path = shell_native_absolute_path( - path.as_str(), - shell_launch_data.as_ref(), - current_working_directory.as_ref(), - ); - - let session = self.active_session.as_ref(ctx).session(ctx); - - let patterns_clone = patterns.clone(); - let conversation_id_clone = input.conversation_id; - let is_file_glob_v2 = is_file_glob_v2(&input); - ActionExecution::new_async( - async move { - match run_file_glob(patterns_clone, absolute_path, session, shell_launch_data) - .with_timeout(FILE_GLOB_TIMEOUT) - .await - { - Ok(result) => result, - Err(_) => Err(anyhow::anyhow!("File glob operation timed out")), - } - }, - move |result, ctx| match result { - Ok(file_glob_result) => { - match file_glob_result { - FileGlobV2Result::Error(ref e) => { - log::warn!("Executing file_glob resulted in error: {e:?}"); - log_file_glob_error(conversation_id_clone, ctx); - } - FileGlobV2Result::Success { .. } => { - send_telemetry_from_app_ctx!( - TelemetryEvent::FileGlobToolSucceeded, - ctx - ); - } - _ => {} - } - // Convert FileGlobV2Result to FileGlobResult if the request was not V2. - if is_file_glob_v2 { - AIAgentActionResultType::FileGlobV2(file_glob_result) - } else { - AIAgentActionResultType::FileGlob(file_glob_result.into()) - } - } - Err(e) => { - log::warn!("Failed to execute file_glob: {e:?}"); - log_file_glob_error(conversation_id_clone, ctx); - if is_file_glob_v2 { - AIAgentActionResultType::FileGlobV2(FileGlobV2Result::Error(e.to_string())) - } else { - AIAgentActionResultType::FileGlob(FileGlobResult::Error(e.to_string())) - } - } - }, - ) - } - - pub(super) fn preprocess_action( - &mut self, - _action: PreprocessActionInput, - _ctx: &mut ModelContext, - ) -> BoxFuture<'static, ()> { - futures::future::ready(()).boxed() - } - - pub(super) fn can_execute_in_parallel(&self, ctx: &AppContext) -> bool { - self.active_session - .as_ref(ctx) - .session(ctx) - .is_some_and(|session| session.supports_parallel_command_execution()) - } -} - -fn is_file_glob_v2(input: &ExecuteActionInput) -> bool { - matches!(input.action.action, AIAgentActionType::FileGlobV2 { .. }) -} - -async fn run_file_glob( +pub(crate) async fn run_file_glob( patterns: Vec, absolute_path: String, session: Option>, @@ -398,10 +215,6 @@ fn non_empty_lines(str: &str) -> impl Iterator { str.lines().filter(|line| !line.is_empty()) } -impl Entity for FileGlobExecutor { - type Event = (); -} - #[cfg(test)] #[path = "file_glob_tests.rs"] mod tests; diff --git a/app/src/ai/blocklist/action_model/execute/grep.rs b/app/src/ai/blocklist/action_model/execute/grep.rs index 962ef9acc7..6f0f49a944 100644 --- a/app/src/ai/blocklist/action_model/execute/grep.rs +++ b/app/src/ai/blocklist/action_model/execute/grep.rs @@ -2,42 +2,21 @@ use std::borrow::Cow; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use std::time::Duration; -use futures::future::BoxFuture; -use futures::FutureExt; use warp_util::standardized_path::StandardizedPath; -use warpui::r#async::FutureExt as AsyncFutureExt; -use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; - -use super::{ - get_server_output_id, is_file_path, is_git_repository, ActionExecution, AnyActionExecution, - ExecuteActionInput, PreprocessActionInput, -}; -use crate::ai::agent::conversation::AIConversationId; -use crate::ai::agent::redaction::redact_secrets; -use crate::ai::agent::{ - AIAgentAction, AIAgentActionResultType, AIAgentActionType, GrepFileMatch, GrepLineMatch, - GrepResult, ServerOutputId, -}; -use crate::ai::blocklist::telemetry_banner::should_collect_ai_ugc_telemetry; -use crate::ai::blocklist::BlocklistAIPermissions; -use crate::ai::paths::{host_native_absolute_path, shell_native_absolute_path}; -use crate::terminal::model::session::active_session::ActiveSession; + +use super::{is_file_path, is_git_repository}; +use crate::ai::agent::{GrepFileMatch, GrepLineMatch, GrepResult}; +use crate::ai::paths::host_native_absolute_path; use crate::terminal::model::session::{shell_quote_arg, ExecuteCommandOptions, Session}; use crate::terminal::shell::ShellType; use crate::terminal::ShellLaunchData; -use crate::{send_telemetry_from_app_ctx, PrivacySettings, TelemetryEvent}; - -const GREP_TIMEOUT: Duration = Duration::from_secs(10); const NON_ZERO_EXIT_CODE_ERROR: &str = "Grep command exited with non-zero exit code"; -/// Information about the Grep call that resulted in an error, used to send -/// telemetry about the error. -struct GrepError { +/// Information about a grep failure. +pub(crate) struct GrepError { command: Option, output: Option, - /// The error message from the Grep call. This should NOT contain UGC. error: GrepErrorType, } @@ -47,9 +26,8 @@ enum GrepErrorType { } impl GrepError { - /// Create a new GrepError with the given error message. This should NOT - /// contain UGC. - pub fn new(error_message: String) -> Self { + /// Creates a grep error with a conversation-safe message. + pub(crate) fn new(error_message: String) -> Self { Self { command: None, output: None, @@ -57,7 +35,8 @@ impl GrepError { } } - pub fn new_for_non_zero_exit_code() -> Self { + /// Creates a grep error for a non-zero command exit. + pub(crate) fn new_for_non_zero_exit_code() -> Self { Self { command: None, output: None, @@ -65,28 +44,21 @@ impl GrepError { } } - pub fn with_command(mut self, command: String) -> Self { + /// Attaches the command that failed. + pub(crate) fn with_command(mut self, command: String) -> Self { self.command = Some(command); self } - pub fn with_output(mut self, output: String) -> Self { + /// Attaches command output from the failed command. + pub(crate) fn with_output(mut self, output: String) -> Self { self.output = Some(output); self } - /// Returns an error message for logging. This should not contain UGC. - pub fn error_message(&self) -> &str { - match &self.error { - GrepErrorType::NonZeroExitCode => NON_ZERO_EXIT_CODE_ERROR, - GrepErrorType::Other(error) => error, - } - } - - /// Returns an error message to be returned as input to the AI conversation. - /// This may contain UGC. - pub fn error_for_conversation(&self) -> String { - match &self { + /// Returns the message to include in the agent conversation. + pub(crate) fn error_for_conversation(&self) -> String { + match self { GrepError { error: GrepErrorType::NonZeroExitCode, output: Some(output), @@ -105,228 +77,6 @@ impl GrepError { } } -#[allow(clippy::too_many_arguments)] -fn create_redacted_grep_error_event( - should_collect_ugc: bool, - server_output_id: Option, - mut queries: Vec, - mut path: String, - shell_type: Option, - mut working_directory: Option, - mut absolute_path: String, - mut error: GrepError, -) -> TelemetryEvent { - for query in queries.iter_mut() { - redact_secrets(query); - } - redact_secrets(&mut path); - if let Some(working_directory) = working_directory.as_mut() { - redact_secrets(working_directory); - } - redact_secrets(&mut absolute_path); - if let Some(command) = error.command.as_mut() { - redact_secrets(command); - } - if let Some(output) = error.output.as_mut() { - redact_secrets(output); - } - - TelemetryEvent::GrepToolFailed { - queries: should_collect_ugc.then_some(queries), - path: should_collect_ugc.then_some(path), - shell_type, - working_directory: should_collect_ugc.then_some(working_directory).flatten(), - absolute_path: should_collect_ugc.then_some(absolute_path), - error: error.error_message().to_string(), - command: should_collect_ugc.then_some(error.command).flatten(), - output: should_collect_ugc.then_some(error.output).flatten(), - server_output_id, - } -} - -#[allow(clippy::too_many_arguments)] -fn log_grep_error( - conversation_id: AIConversationId, - queries: Vec, - path: String, - shell_type: Option, - working_directory: Option, - absolute_path: String, - error: GrepError, - ctx: &mut AppContext, -) { - let should_collect_ugc = should_collect_ai_ugc_telemetry( - ctx, - PrivacySettings::handle(ctx) - .as_ref(ctx) - .is_telemetry_enabled, - ); - let server_output_id = get_server_output_id(conversation_id, ctx); - - let event = create_redacted_grep_error_event( - should_collect_ugc, - server_output_id, - queries, - path, - shell_type, - working_directory, - absolute_path, - error, - ); - send_telemetry_from_app_ctx!(event, ctx); -} - -pub struct GrepExecutor { - active_session: ModelHandle, - terminal_view_id: EntityId, -} - -impl GrepExecutor { - pub fn new(active_session: ModelHandle, terminal_view_id: EntityId) -> Self { - Self { - active_session, - terminal_view_id, - } - } - - pub(super) fn should_autoexecute( - &self, - input: ExecuteActionInput, - ctx: &mut ModelContext, - ) -> bool { - let ExecuteActionInput { - action: - AIAgentAction { - action: AIAgentActionType::Grep { path, .. }, - .. - }, - conversation_id, - } = input - else { - return false; - }; - - let current_working_directory = self - .active_session - .as_ref(ctx) - .current_working_directory() - .cloned(); - let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx); - let absolute_path = host_native_absolute_path(path, &shell, ¤t_working_directory); - - BlocklistAIPermissions::handle(ctx) - .as_ref(ctx) - .can_read_files_with_conversation( - &conversation_id, - vec![PathBuf::from(absolute_path)], - Some(self.terminal_view_id), - ctx, - ) - .is_allowed() - } - - pub(super) fn execute( - &mut self, - input: ExecuteActionInput, - ctx: &mut ModelContext, - ) -> impl Into { - let AIAgentAction { - action: AIAgentActionType::Grep { queries, path }, - .. - } = input.action - else { - return ActionExecution::InvalidAction; - }; - - let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx); - let shell_type = self.active_session.as_ref(ctx).shell_type(ctx); - let current_working_directory = self - .active_session - .as_ref(ctx) - .current_working_directory() - .cloned(); - let absolute_path = shell_native_absolute_path( - path, - shell_launch_data.as_ref(), - current_working_directory.as_ref(), - ); - - let session = self.active_session.as_ref(ctx).session(ctx); - - let path_clone = path.clone(); - let queries_clone = queries.clone(); - let other_queries_clone = queries.clone(); - let absolute_path_clone = absolute_path.clone(); - let working_directory_clone = current_working_directory.clone(); - let conversation_id_clone = input.conversation_id; - ActionExecution::new_async( - async move { - match run_grep(queries_clone, absolute_path, session, shell_launch_data) - .with_timeout(GREP_TIMEOUT) - .await - { - Ok(result) => result, - Err(_) => Err(GrepError::new("Grep operation timed out".to_string())), - } - }, - move |result, ctx| match result { - Ok(grep_result) => { - match grep_result { - GrepResult::Error(ref e) => { - log::warn!("Executing grep resulted in error: {e:?}"); - log_grep_error( - conversation_id_clone, - other_queries_clone, - path_clone, - shell_type, - working_directory_clone, - absolute_path_clone, - GrepError::new(e.to_string()), - ctx, - ); - } - GrepResult::Success { .. } => { - send_telemetry_from_app_ctx!(TelemetryEvent::GrepToolSucceeded, ctx); - } - _ => {} - } - AIAgentActionResultType::Grep(grep_result) - } - Err(e) => { - log::warn!("Failed to execute grep: {:?}", e.error_message()); - let error_for_conversation = e.error_for_conversation(); - log_grep_error( - conversation_id_clone, - other_queries_clone, - path_clone, - shell_type, - working_directory_clone, - absolute_path_clone, - e, - ctx, - ); - AIAgentActionResultType::Grep(GrepResult::Error(error_for_conversation)) - } - }, - ) - } - - pub(super) fn preprocess_action( - &mut self, - _action: PreprocessActionInput, - _ctx: &mut ModelContext, - ) -> BoxFuture<'static, ()> { - futures::future::ready(()).boxed() - } - - pub(super) fn can_execute_in_parallel(&self, ctx: &AppContext) -> bool { - self.active_session - .as_ref(ctx) - .session(ctx) - .is_some_and(|session| session.supports_parallel_command_execution()) - } -} - /// Runs a grep-like search to find the files and line numbers that match the queries. /// /// Depending on the environment, this uses the most optimized tool to perform the search: @@ -335,7 +85,7 @@ impl GrepExecutor { /// - otherwise, if the search is against the local file system, we run `ripgrep` via the library. /// `ripgrep` is a more optimized version of `grep`. /// - otherwise, we run vanilla `grep` in the session -async fn run_grep( +pub(crate) async fn run_grep( queries: Vec, absolute_path: String, session: Option>, @@ -685,10 +435,6 @@ fn parse_grep_output( .collect()) } -impl Entity for GrepExecutor { - type Event = (); -} - #[cfg(test)] #[path = "grep_tests.rs"] mod tests; diff --git a/app/src/ai/blocklist/action_model/execute/grep_tests.rs b/app/src/ai/blocklist/action_model/execute/grep_tests.rs index 9a23fa4032..1046684862 100644 --- a/app/src/ai/blocklist/action_model/execute/grep_tests.rs +++ b/app/src/ai/blocklist/action_model/execute/grep_tests.rs @@ -1,78 +1,6 @@ use super::*; -use crate::terminal::model::secrets::regexes::FIREBASE_AUTH_DOMAIN; use crate::terminal::shell::ShellType; -#[test] -fn test_create_redacted_grep_error_event() { - crate::terminal::model::set_user_and_enterprise_secret_regexes( - [®ex::Regex::new(FIREBASE_AUTH_DOMAIN).expect("Should be able to construct regex")], - std::iter::empty(), // No enterprise secrets - ); - - // Create input with a known secret pattern (Firebase domain) - let queries = vec![ - "normal query".to_string(), - "query with warp-server-staging.firebaseapp.com secret".to_string(), - ]; - let path = "path/to/file/with/warp-server-staging.firebaseapp.com/secret".to_string(); - let shell_type = Some(ShellType::Bash); - let working_directory = Some("/users/test/warp-server-staging.firebaseapp.com".to_string()); - let absolute_path = - "/absolute/path/with/warp-server-staging.firebaseapp.com/secret".to_string(); - let error = GrepError::new("Error message".to_string()) - .with_command("grep warp-server-staging.firebaseapp.com".to_string()) - .with_output("Output with warp-server-staging.firebaseapp.com".to_string()); - - // Call the function with the test inputs - let event = create_redacted_grep_error_event( - true, - None, - queries.clone(), - path.clone(), - shell_type, - working_directory.clone(), - absolute_path.clone(), - error, - ); - - // Verify the telemetry event has redacted secrets - if let TelemetryEvent::GrepToolFailed { - queries: Some(redacted_queries), - path: Some(redacted_path), - shell_type: _, - working_directory: Some(redacted_working_directory), - absolute_path: Some(redacted_absolute_path), - command: Some(redacted_command), - output: Some(redacted_output), - error: _, - server_output_id: _, - } = event - { - // Verify secrets are redacted from all relevant fields - assert_eq!(redacted_queries.len(), 2); - assert_eq!(redacted_queries[0], "normal query"); - assert!(!redacted_queries[1].contains("warp-server-staging.firebaseapp.com")); - assert!(redacted_queries[1].contains("*****")); - - assert!(!redacted_path.contains("warp-server-staging.firebaseapp.com")); - assert!(redacted_path.contains("*****")); - - assert!(!redacted_working_directory.contains("warp-server-staging.firebaseapp.com")); - assert!(redacted_working_directory.contains("*****")); - - assert!(!redacted_absolute_path.contains("warp-server-staging.firebaseapp.com")); - assert!(redacted_absolute_path.contains("*****")); - - assert!(!redacted_command.contains("warp-server-staging.firebaseapp.com")); - assert!(redacted_command.contains("*****")); - - assert!(!redacted_output.contains("warp-server-staging.firebaseapp.com")); - assert!(redacted_output.contains("*****")); - } else { - panic!("Expected GrepToolFailed event"); - } -} - #[test] fn build_git_grep_command_single_quotes_shell_substitution() { let queries = vec!["$(touch /tmp/warp-poc); `id`".to_string()]; diff --git a/app/src/ai/blocklist/action_model/execute/read_files.rs b/app/src/ai/blocklist/action_model/execute/read_files.rs deleted file mode 100644 index d9fdf2d812..0000000000 --- a/app/src/ai/blocklist/action_model/execute/read_files.rs +++ /dev/null @@ -1,272 +0,0 @@ -use std::path::{Path, PathBuf}; - -use futures::future::BoxFuture; -use futures::FutureExt; -use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; - -use super::{ - read_local_file_context, ActionExecution, AnyActionExecution, ExecuteActionInput, - PreprocessActionInput, -}; -use crate::ai::agent::{ - AIAgentAction, AIAgentActionResultType, AIAgentActionType, ReadFilesRequest, ReadFilesResult, -}; -use crate::ai::blocklist::BlocklistAIPermissions; -use crate::ai::paths::host_native_absolute_path; -use crate::terminal::model::session::active_session::ActiveSession; -use crate::terminal::model::session::SessionType; - -pub struct ReadFilesExecutor { - active_session: ModelHandle, - terminal_view_id: EntityId, -} - -impl ReadFilesExecutor { - pub fn new(active_session: ModelHandle, terminal_view_id: EntityId) -> Self { - Self { - active_session, - terminal_view_id, - } - } - - pub(super) fn should_autoexecute( - &self, - input: ExecuteActionInput, - ctx: &mut ModelContext, - ) -> bool { - let ExecuteActionInput { - action: - AIAgentAction { - action: AIAgentActionType::ReadFiles(ReadFilesRequest { locations }), - .. - }, - conversation_id, - } = input - else { - return false; - }; - - // TODO: figure out how to avoid constructing the full paths in `should_execute` - // and then again in `execute`, and then again on every render. - let current_working_directory = self - .active_session - .as_ref(ctx) - .current_working_directory() - .cloned(); - let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx); - - BlocklistAIPermissions::as_ref(ctx) - .can_read_files_with_conversation( - &conversation_id, - locations - .iter() - .map(|file| { - PathBuf::from(host_native_absolute_path( - &file.name, - &shell, - ¤t_working_directory, - )) - }) - .collect(), - Some(self.terminal_view_id), - ctx, - ) - .is_allowed() - } - - pub(super) fn execute( - &mut self, - input: ExecuteActionInput, - ctx: &mut ModelContext, - ) -> impl Into { - let ExecuteActionInput { - action, - conversation_id, - .. - } = input; - let AIAgentAction { - action: AIAgentActionType::ReadFiles(ReadFilesRequest { locations }), - .. - } = action - else { - return ActionExecution::InvalidAction; - }; - - BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| { - model.add_temporary_file_read_permissions( - conversation_id, - locations.iter().map(|file| Path::new(&file.name)), - ); - }); - - let current_working_directory = self - .active_session - .as_ref(ctx) - .current_working_directory() - .cloned(); - let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx); - - let locations = locations.clone(); - - // Check if this is a remote session with a connected host. - let session_type = self.active_session.as_ref(ctx).session_type(ctx); - let host_request_handle = match &session_type { - Some(SessionType::WarpifiedRemote { - host_id: Some(host_id), - }) => Some( - remote_server::manager::RemoteServerManager::as_ref(ctx) - .host_request_handle(host_id), - ), - _ => None, - }; - - // Remote session without a usable remote server connection. File reading - // requires either local access or a connected remote server, neither - // of which is available. - if matches!(session_type, Some(SessionType::WarpifiedRemote { .. })) - && host_request_handle.is_none() - { - return ActionExecution::Sync(AIAgentActionResultType::ReadFiles( - ReadFilesResult::Error( - "The file read/edit tool is not available on this remote session. \ - Try using a different tool." - .to_string(), - ), - )); - } - - if let Some(handle) = host_request_handle { - return ActionExecution::Async { - execute_future: Box::pin(async move { - let request = remote_server::proto::ReadFileContextRequest { - files: locations - .iter() - .map(|loc| { - let absolute_path = host_native_absolute_path( - &loc.name, - &shell, - ¤t_working_directory, - ); - remote_server::proto::ReadFileContextFile { - path: absolute_path, - line_ranges: loc - .lines - .iter() - .map(|r| remote_server::proto::LineRange { - start: r.start as u32, - end: r.end as u32, - }) - .collect(), - } - }) - .collect(), - max_file_bytes: None, - max_batch_bytes: None, - }; - - let response = handle - .read_file_context(request) - .await - .map_err(|e| anyhow::anyhow!("Remote read failed: {e}"))?; - - if !response.failed_files.is_empty() && response.file_contexts.is_empty() { - let failed = response - .failed_files - .iter() - .map(|f| { - let reason = f - .error - .as_ref() - .map(|e| e.message.as_str()) - .unwrap_or("unknown error"); - format!("{}: {reason}", f.path) - }) - .collect::>() - .join(", "); - return Ok(ReadFilesResult::Error(format!( - "Failed to read files: {failed}" - ))); - } - - let file_contexts = response - .file_contexts - .into_iter() - .filter_map(|fc| { - let content = match fc.content? { - remote_server::proto::file_context_proto::Content::TextContent( - text, - ) => crate::ai::agent::AnyFileContent::StringContent(text), - remote_server::proto::file_context_proto::Content::BinaryContent( - bytes, - ) => crate::ai::agent::AnyFileContent::BinaryContent(bytes), - }; - let line_range = match (fc.line_range_start, fc.line_range_end) { - (Some(start), Some(end)) => Some(start as usize..end as usize), - _ => None, - }; - let last_modified = fc.last_modified_epoch_millis.map(|ms| { - std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms) - }); - Some(crate::ai::agent::FileContext { - file_name: fc.file_name, - content, - line_range, - last_modified, - line_count: fc.line_count as usize, - }) - }) - .collect(); - - Ok(ReadFilesResult::Success { - files: file_contexts, - }) - }), - on_complete: Box::new(|res: Result, _ctx| { - let action_result = - res.unwrap_or_else(|e| ReadFilesResult::Error(e.to_string())); - AIAgentActionResultType::ReadFiles(action_result) - }), - }; - } - - // Local path. - ActionExecution::Async { - execute_future: Box::pin(async move { - let result = read_local_file_context( - &locations, - current_working_directory, - shell, - None, - None, - ) - .await?; - if result.missing_files.is_empty() { - Ok(ReadFilesResult::Success { - files: result.file_contexts, - }) - } else { - let missing_files = result.missing_files.join(", "); - Ok(ReadFilesResult::Error(format!( - "These files do not exist: {missing_files}" - ))) - } - }), - on_complete: Box::new(|res: Result, _ctx| { - let action_result = res.unwrap_or_else(|e| ReadFilesResult::Error(e.to_string())); - AIAgentActionResultType::ReadFiles(action_result) - }), - } - } - - pub(super) fn preprocess_action( - &mut self, - _input: PreprocessActionInput, - _ctx: &mut ModelContext, - ) -> BoxFuture<'static, ()> { - futures::future::ready(()).boxed() - } -} - -impl Entity for ReadFilesExecutor { - type Event = (); -} diff --git a/app/src/ai/blocklist/action_model/scheduler.rs b/app/src/ai/blocklist/action_model/scheduler.rs new file mode 100644 index 0000000000..44ee97bf14 --- /dev/null +++ b/app/src/ai/blocklist/action_model/scheduler.rs @@ -0,0 +1,491 @@ +//! Shared agent-tool scheduling loop, parameterized over [`AgentToolScheduleHost`]. +//! +//! [`AgentToolScheduler`] owns the preprocessing fan-out, pending queue, serial/parallel phase +//! admission, ordered result draining, and follow-up readiness logic. Both the GUI +//! [`BlocklistAIActionModel`] and the TUI [`TuiToolActionModel`] implement [`AgentToolScheduleHost`] +//! and delegate scheduling to the static methods on [`AgentToolScheduler`]. + +use std::collections::HashSet; +use std::sync::Arc; + +use futures::future::BoxFuture; +use warpui::AppContext; + +use super::execute::{RunningActionPhase, TryExecuteResult}; +use super::preprocess::PreprocessId; +use super::tool_action_model::AgentToolActionModel; +use super::NotExecutedReason; +use crate::ai::agent::conversation::AIConversationId; +use crate::ai::agent::{ + AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, + AIAgentActionType, CancellationReason, RequestCommandOutputResult, +}; + +// ─── StartedAction ───────────────────────────────────────────────────────────── + +/// Outcome of attempting to start one pending action. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum StartedAction { + Sync, + Async { phase: RunningActionPhase }, +} + +// ─── can_start_action_with_current_phase ─────────────────────────────────────── + +/// Returns whether another action may join the currently running phase. +/// +/// Parallel phases only admit additional actions that classify into the same group and +/// can still be auto-executed. Serial phases always act as a barrier. +pub(super) fn can_start_action_with_current_phase( + current_phase: RunningActionPhase, + next_phase: RunningActionPhase, + can_autoexecute: bool, +) -> bool { + match current_phase { + RunningActionPhase::Serial => false, + RunningActionPhase::Parallel(group) => { + next_phase == RunningActionPhase::Parallel(group) && can_autoexecute + } + } +} + +// ─── AgentToolScheduleHost ───────────────────────────────────────────────────── + +/// Implemented by surfaces (GUI, TUI) to plug surface-specific behavior into +/// the shared scheduling loop. +pub(crate) trait AgentToolScheduleHost: Sized { + type Context<'a>; + + /// Returns the [`AppContext`] for the current call. + fn app_context<'a, 'b>(ctx: &'a Self::Context<'b>) -> &'a AppContext; + + /// Mutable access to shared action-queue state. + fn tools(&mut self) -> &mut AgentToolActionModel; + + /// Read-only access to shared action-queue state. + fn tools_ref(&self) -> &AgentToolActionModel; + + /// Preprocessing step for a single action. Returns a future that resolves when done. + fn preprocess( + &mut self, + action: &AIAgentAction, + conversation_id: AIConversationId, + ctx: &mut Self::Context<'_>, + ) -> BoxFuture<'static, ()>; + + /// Attempts to execute the action. The host guarantees that + /// [`AgentToolScheduler::finish_action`] is eventually called for async results. + fn try_execute( + &mut self, + action: AIAgentAction, + conversation_id: AIConversationId, + is_user_initiated: bool, + ctx: &mut Self::Context<'_>, + ) -> TryExecuteResult; + + /// Returns whether the host would auto-execute this action without user confirmation. + fn can_autoexecute( + &mut self, + action: &AIAgentAction, + conversation_id: AIConversationId, + ctx: &mut Self::Context<'_>, + ) -> bool; + + /// Returns the execution phase for this action (Serial or Parallel(group)). + fn action_phase(&self, action: &AIAgentAction, ctx: &AppContext) -> RunningActionPhase; + + /// Spawns `futures` and calls `then` when they all complete. Implemented by each + /// host with its concrete context's `ctx.spawn`. + fn spawn_after_preprocess( + &mut self, + futures: Vec>, + ctx: &mut Self::Context<'_>, + then: impl FnOnce(&mut Self, &mut Self::Context<'_>) + 'static, + ); + + // ── Side-effect hooks (defaults = no-op) ────────────────────────────── + + /// Returns false to suppress enqueueing this action (e.g. view-only guard). + fn should_enqueue( + &self, + _conversation_id: AIConversationId, + _action_id: &AIAgentActionId, + _ctx: &AppContext, + ) -> bool { + true + } + + /// Called when an action is added to the pending queue. + fn on_action_enqueued( + &mut self, + _conversation_id: AIConversationId, + _action_id: &AIAgentActionId, + _ctx: &mut Self::Context<'_>, + ) { + } + + /// Called when an action transitions to started/running. + fn on_action_started( + &mut self, + _conversation_id: AIConversationId, + _is_wait_for_events: bool, + _ctx: &mut Self::Context<'_>, + ) { + } + + /// Called when an action could not be started (needs confirmation, not ready, etc.). + fn on_action_not_executed( + &mut self, + _action: &AIAgentAction, + _reason: NotExecutedReason, + _conversation_id: AIConversationId, + _ctx: &mut Self::Context<'_>, + ) { + } + + /// Called after an action's result is recorded, before checking phase drain. + fn on_action_finished( + &mut self, + _conversation_id: AIConversationId, + _result: &Arc, + _cancellation_reason: Option, + _ctx: &mut Self::Context<'_>, + ) { + } + + /// Called when the current running phase has fully drained and there are no more + /// pending actions for this conversation. + fn on_phase_drained( + &mut self, + _conversation_id: AIConversationId, + _cancellation_reason: Option, + _ctx: &mut Self::Context<'_>, + ) { + } +} + +// ─── AgentToolScheduler ──────────────────────────────────────────────────────── + +/// Unit struct whose generic static methods own the agent-tool scheduling loop. +pub(crate) struct AgentToolScheduler; + +impl AgentToolScheduler { + /// Queues `actions` for the given conversation: records order, runs preprocessing, + /// and after all preprocessing completes schedules the first batch of actions. + pub(crate) fn queue_actions( + host: &mut H, + actions: Vec, + conversation_id: AIConversationId, + ctx: &mut H::Context<'_>, + ) { + host.tools().record_action_order(conversation_id, &actions); + let mut preprocess_futures = Vec::with_capacity(actions.len()); + let mut action_ids = HashSet::with_capacity(actions.len()); + + for action in actions.iter() { + action_ids.insert(action.id.clone()); + preprocess_futures.push(host.preprocess(action, conversation_id, ctx)); + } + + let preprocess_id = host + .tools() + .pending_preprocessed_actions + .entry(conversation_id) + .or_default() + .insert_preprocess_action_batch(action_ids); + + host.spawn_after_preprocess(preprocess_futures, ctx, move |host, ctx| { + AgentToolScheduler::handle_preprocess_actions_results( + host, + conversation_id, + preprocess_id, + actions, + ctx, + ); + }); + } + + /// Records a completed action result, updates running state, and schedules follow-up work. + pub(crate) fn finish_action( + host: &mut H, + conversation_id: AIConversationId, + action_result: Arc, + cancellation_reason: Option, + ctx: &mut H::Context<'_>, + ) { + host.tools() + .finish_running_action(conversation_id, &action_result.id); + + // If a command entered long-running mode, cancel all other pending RequestCommandOutput + // actions. Only one command can be active at a time. + if matches!( + &action_result.result, + AIAgentActionResultType::RequestCommandOutput( + RequestCommandOutputResult::LongRunningCommandSnapshot { .. } + ) + ) { + for action in AgentToolScheduler::drain_pending_request_command_actions( + host.tools(), + conversation_id, + ) { + let result = Arc::new(AIAgentActionResult { + id: action.id, + task_id: action.task_id, + result: action.action.cancelled_result(), + }); + AgentToolScheduler::finish_action( + host, + conversation_id, + result, + cancellation_reason, + ctx, + ); + } + } + + host.tools() + .push_finished_result(conversation_id, action_result.clone()); + host.on_action_finished(conversation_id, &action_result, cancellation_reason, ctx); + + if host + .tools_ref() + .running_actions + .get(&conversation_id) + .is_some_and(|running| !running.is_empty()) + { + // Wait until the entire phase drains before scheduling subsequent actions. + return; + } + + // Phase fully drained — sort results back into original tool-call order. + let action_order = host.tools_ref().action_order.get(&conversation_id).cloned(); + if let Some(action_order) = action_order { + if let Some(finished_results) = host + .tools() + .finished_action_results + .get_mut(&conversation_id) + { + finished_results.sort_by_key(|result| { + action_order.get(&result.id).copied().unwrap_or(usize::MAX) + }); + } + } + + if host + .tools_ref() + .pending_actions + .get(&conversation_id) + .is_none_or(|actions| actions.is_empty()) + { + host.on_phase_drained(conversation_id, cancellation_reason, ctx); + } else { + AgentToolScheduler::try_to_execute_available_actions(host, conversation_id, ctx); + } + } + + /// Advances the scheduling loop: starts as many pending actions as the current phase allows. + pub(super) fn try_to_execute_available_actions( + host: &mut H, + conversation_id: AIConversationId, + ctx: &mut H::Context<'_>, + ) { + loop { + let Some(front_action) = host + .tools_ref() + .pending_actions + .get(&conversation_id) + .and_then(|queue| queue.front()) + .cloned() + else { + return; + }; + + if let Some(current_phase) = host + .tools_ref() + .running_actions + .get(&conversation_id) + .map(|r| r.phase) + { + if !AgentToolScheduler::can_start_action_in_current_phase( + host, + &front_action, + conversation_id, + current_phase, + ctx, + ) { + return; + } + } + + let Some(result) = AgentToolScheduler::start_pending_action_by_id( + host, + &front_action.id, + conversation_id, + false, + ctx, + ) else { + return; + }; + + if matches!( + result, + StartedAction::Async { + phase: RunningActionPhase::Serial + } + ) { + return; + } + } + } + + /// Removes the pending action with `action_id` from the queue and starts it. + /// + /// Returns `None` if the action could not be started. + pub(super) fn start_pending_action_by_id( + host: &mut H, + action_id: &AIAgentActionId, + conversation_id: AIConversationId, + is_user_initiated: bool, + ctx: &mut H::Context<'_>, + ) -> Option { + if is_user_initiated + && host + .tools_ref() + .running_actions + .contains_key(&conversation_id) + { + // User-driven approvals execute one action at a time so interactive + // confirmations do not overlap. + return None; + } + + let idx = host + .tools_ref() + .pending_actions + .get(&conversation_id) + .and_then(|queue| queue.iter().position(|action| &action.id == action_id))?; + + let action = host + .tools() + .pending_actions + .get_mut(&conversation_id)? + .remove(idx)?; + + let action_id_clone = action.id.clone(); + let phase = host.action_phase(&action, H::app_context(ctx)); + // WaitForEvents owns its own status transition; skip the default in-progress update. + let is_wait_for_events = matches!(action.action, AIAgentActionType::WaitForEvents { .. }); + let execute_result = host.try_execute(action, conversation_id, is_user_initiated, ctx); + + match execute_result { + TryExecuteResult::ExecutedAsync => { + host.on_action_started(conversation_id, is_wait_for_events, ctx); + host.tools() + .record_running_action(conversation_id, action_id_clone, phase); + Some(StartedAction::Async { phase }) + } + TryExecuteResult::ExecutedSync => { + host.on_action_started(conversation_id, is_wait_for_events, ctx); + Some(StartedAction::Sync) + } + TryExecuteResult::NotExecuted { reason, action } => { + host.tools() + .pending_actions + .entry(conversation_id) + .or_default() + .insert(idx, (*action).clone()); + host.on_action_not_executed(action.as_ref(), reason, conversation_id, ctx); + None + } + } + } + + /// Called after preprocessing completes for a batch; enqueues actions in order and + /// kicks off the scheduling loop. + fn handle_preprocess_actions_results( + host: &mut H, + conversation_id: AIConversationId, + preprocess_id: PreprocessId, + actions: Vec, + ctx: &mut H::Context<'_>, + ) { + let actions_to_enqueue = host + .tools() + .pending_preprocessed_actions + .entry(conversation_id) + .or_default() + .handle_preprocess_actions_result(preprocess_id, actions); + + for action in actions_to_enqueue { + let action_id = action.id.clone(); + // Skip actions that already have results (can happen in session sharing when the + // sharer finishes and sends a result while preprocessing is still running). + if host + .tools_ref() + .finished_action_results + .get(&conversation_id) + .is_some_and(|results| results.iter().any(|r| r.id == action_id)) + { + continue; + } + + if !host.should_enqueue(conversation_id, &action_id, H::app_context(ctx)) { + continue; + } + + host.tools() + .pending_actions + .entry(conversation_id) + .or_default() + .push_back(action); + host.on_action_enqueued(conversation_id, &action_id, ctx); + } + AgentToolScheduler::try_to_execute_available_actions(host, conversation_id, ctx); + } + + /// Returns whether `action` may start alongside the currently running `current_phase`. + fn can_start_action_in_current_phase( + host: &mut H, + action: &AIAgentAction, + conversation_id: AIConversationId, + current_phase: RunningActionPhase, + ctx: &mut H::Context<'_>, + ) -> bool { + // Recompute phase on demand so executor-side capability checks use latest runtime state. + let next_phase = host.action_phase(action, H::app_context(ctx)); + let can_autoexecute = host.can_autoexecute(action, conversation_id, ctx); + can_start_action_with_current_phase(current_phase, next_phase, can_autoexecute) + } + + /// Removes and returns all pending `RequestCommandOutput` actions for a conversation. + fn drain_pending_request_command_actions( + tools: &mut AgentToolActionModel, + conversation_id: AIConversationId, + ) -> Vec { + let Some(pending_actions) = tools.pending_actions.get_mut(&conversation_id) else { + return Vec::new(); + }; + + let mut to_drain = Vec::new(); + let mut i = 0; + while i < pending_actions.len() { + if matches!( + pending_actions[i].action, + AIAgentActionType::RequestCommandOutput { .. } + ) { + to_drain.push( + pending_actions + .remove(i) + .expect("index is valid because i < pending_actions.len()"), + ); + } else { + i += 1; + } + } + to_drain + } +} + +#[cfg(test)] +#[path = "scheduler_tests.rs"] +mod tests; diff --git a/app/src/ai/blocklist/action_model/scheduler_tests.rs b/app/src/ai/blocklist/action_model/scheduler_tests.rs new file mode 100644 index 0000000000..e276b7a193 --- /dev/null +++ b/app/src/ai/blocklist/action_model/scheduler_tests.rs @@ -0,0 +1,201 @@ +use futures::future::BoxFuture; +use futures::FutureExt; +use warpui::{App, AppContext, Entity, ModelContext}; + +use super::super::execute::{ParallelExecutionPolicy, RunningActionPhase, TryExecuteResult}; +use super::{AgentToolActionModel, AgentToolScheduleHost, AgentToolScheduler}; +use crate::ai::agent::conversation::AIConversationId; +use crate::ai::agent::task::TaskId; +use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionType}; + +/// A minimal mock host for deterministic scheduler tests. +/// +/// `try_execute` returns `ExecutedAsync` but never calls `finish_action`, so admitted +/// actions stay "running" forever — making the post-queue state stable to assert on. +/// `spawn_after_preprocess` invokes its callback synchronously, so the entire +/// queue_actions flow completes within the same `app.update()` call. +struct MockHost { + tools: AgentToolActionModel, +} + +impl Entity for MockHost { + type Event = (); +} + +impl AgentToolScheduleHost for MockHost { + type Context<'a> = ModelContext<'a, Self>; + + fn app_context<'a, 'b>(ctx: &'a Self::Context<'b>) -> &'a AppContext { + ctx + } + + fn tools(&mut self) -> &mut AgentToolActionModel { + &mut self.tools + } + + fn tools_ref(&self) -> &AgentToolActionModel { + &self.tools + } + + fn preprocess( + &mut self, + _action: &AIAgentAction, + _conversation_id: AIConversationId, + _ctx: &mut Self::Context<'_>, + ) -> BoxFuture<'static, ()> { + futures::future::ready(()).boxed() + } + + fn try_execute( + &mut self, + _action: AIAgentAction, + _conversation_id: AIConversationId, + _is_user_initiated: bool, + _ctx: &mut Self::Context<'_>, + ) -> TryExecuteResult { + // Never calls finish_action — leaves the action "running" so state is stable. + TryExecuteResult::ExecutedAsync + } + + fn can_autoexecute( + &mut self, + _action: &AIAgentAction, + _conversation_id: AIConversationId, + _ctx: &mut Self::Context<'_>, + ) -> bool { + true + } + + fn action_phase(&self, action: &AIAgentAction, _ctx: &AppContext) -> RunningActionPhase { + match &action.action { + AIAgentActionType::ReadFiles(_) => { + RunningActionPhase::Parallel(ParallelExecutionPolicy::ReadOnlyLocalContext) + } + _ => RunningActionPhase::Serial, + } + } + + fn spawn_after_preprocess( + &mut self, + _futures: Vec>, + ctx: &mut Self::Context<'_>, + then: impl FnOnce(&mut Self, &mut Self::Context<'_>) + 'static, + ) { + // Run synchronously so queue_actions completes within one update call. + then(self, ctx); + } +} + +/// Returns a minimal `AIAgentAction` with the given discriminant. +fn make_action(id: &str, action_type: AIAgentActionType) -> AIAgentAction { + AIAgentAction { + id: AIAgentActionId::from(id.to_owned()), + task_id: TaskId::new("task".to_owned()), + action: action_type, + requires_result: true, + } +} + +fn make_read_files(id: &str) -> AIAgentAction { + use ai::agent::action::ReadFilesRequest; + + use crate::ai::agent::FileLocations; + make_action( + id, + AIAgentActionType::ReadFiles(ReadFilesRequest { + locations: vec![FileLocations { + name: "/dev/null".to_string(), + lines: vec![], + }], + }), + ) +} + +fn make_file_edits(id: &str) -> AIAgentAction { + make_action( + id, + AIAgentActionType::RequestFileEdits { + file_edits: vec![], + title: None, + }, + ) +} + +fn make_command(id: &str) -> AIAgentAction { + make_action( + id, + AIAgentActionType::RequestCommandOutput { + command: "echo hi".to_string(), + is_read_only: None, + is_risky: None, + wait_until_completion: true, + uses_pager: None, + rationale: None, + citations: vec![], + }, + ) +} + +#[test] +fn scheduler_admits_parallel_read_phase() { + App::test((), |mut app| async move { + let handle = app.add_model(|_| MockHost { + tools: AgentToolActionModel::new(), + }); + let conversation_id = AIConversationId::new(); + + handle.update(&mut app, |host, ctx| { + AgentToolScheduler::queue_actions( + host, + vec![make_read_files("r1"), make_read_files("r2")], + conversation_id, + ctx, + ); + }); + + handle.read(&app, |host, _| { + assert_eq!( + host.tools.running_action_count(conversation_id), + 2, + "both ReadFiles should be running in parallel" + ); + assert_eq!( + host.tools.pending_action_count(conversation_id), + 0, + "no actions should be left pending" + ); + }); + }); +} + +#[test] +fn scheduler_serial_barrier_holds_second_action() { + App::test((), |mut app| async move { + let handle = app.add_model(|_| MockHost { + tools: AgentToolActionModel::new(), + }); + let conversation_id = AIConversationId::new(); + + handle.update(&mut app, |host, ctx| { + AgentToolScheduler::queue_actions( + host, + vec![make_file_edits("e1"), make_command("c1")], + conversation_id, + ctx, + ); + }); + + handle.read(&app, |host, _| { + assert_eq!( + host.tools.running_action_count(conversation_id), + 1, + "only the first serial action should be running" + ); + assert_eq!( + host.tools.pending_action_count(conversation_id), + 1, + "the second action should be blocked behind the serial barrier" + ); + }); + }); +} diff --git a/app/src/ai/blocklist/action_model/tool_action_model.rs b/app/src/ai/blocklist/action_model/tool_action_model.rs new file mode 100644 index 0000000000..e0f216fe2f --- /dev/null +++ b/app/src/ai/blocklist/action_model/tool_action_model.rs @@ -0,0 +1,304 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; + +use super::preprocess::PendingPreprocessedActions; +use super::{AIActionStatus, RunningActionPhase, RunningActions}; +use crate::ai::agent::conversation::AIConversationId; +use crate::ai::agent::{ + AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, AIAgentExchange, + AIAgentInput, RequestCommandOutputResult, +}; + +/// Shared action queue/result state for Agent Mode tools. +pub(crate) struct AgentToolActionModel { + pub(super) pending_preprocessed_actions: HashMap, + pub(super) pending_actions: HashMap>, + pub(super) running_actions: HashMap, + pub(super) finished_action_results: HashMap>>, + pub(super) action_order: HashMap>, + pub(super) past_action_results: HashMap>, +} + +impl AgentToolActionModel { + pub(crate) fn new() -> Self { + Self { + pending_preprocessed_actions: Default::default(), + pending_actions: Default::default(), + running_actions: Default::default(), + finished_action_results: Default::default(), + action_order: Default::default(), + past_action_results: Default::default(), + } + } + + /// Records the dispatch order of a batch of actions so results can be sorted back + /// into the original tool-call order when the batch drains. + pub(crate) fn record_action_order( + &mut self, + conversation_id: AIConversationId, + actions: &[AIAgentAction], + ) { + self.action_order.insert( + conversation_id, + actions + .iter() + .enumerate() + .map(|(index, action)| (action.id.clone(), index)) + .collect(), + ); + } + + /// Records an action as currently running in the given conversation. + /// + /// Asserts that any existing running phase for this conversation matches the new + /// action's phase, since a phase must drain before actions from a different phase + /// are admitted. + pub(crate) fn record_running_action( + &mut self, + conversation_id: AIConversationId, + action_id: AIAgentActionId, + phase: RunningActionPhase, + ) { + match self.running_actions.entry(conversation_id) { + std::collections::hash_map::Entry::Occupied(mut entry) => { + debug_assert_eq!(entry.get().phase, phase); + entry.get_mut().add_action(action_id); + } + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(RunningActions::new(phase, action_id)); + } + } + } + + /// Removes the given action from the running set; clears the conversation entry when empty. + pub(crate) fn finish_running_action( + &mut self, + conversation_id: AIConversationId, + action_id: &AIAgentActionId, + ) { + let should_remove = self + .running_actions + .get_mut(&conversation_id) + .is_some_and(|running| { + running.remove_action(action_id); + running.is_empty() + }); + if should_remove { + self.running_actions.remove(&conversation_id); + } + } + + pub(crate) fn push_finished_result( + &mut self, + conversation_id: AIConversationId, + result: Arc, + ) { + self.finished_action_results + .entry(conversation_id) + .or_default() + .push(result); + } + + /// Returns the pending action with the given ID, if any. + #[cfg_attr(not(feature = "tui"), allow(dead_code))] + pub(crate) fn find_pending_action( + &self, + conversation_id: AIConversationId, + action_id: &AIAgentActionId, + ) -> Option<&AIAgentAction> { + self.pending_actions + .get(&conversation_id) + .and_then(|q| q.iter().find(|a| &a.id == action_id)) + } + + /// Returns the next pending action for a conversation, or `None` if a phase is running + /// (running phases block new actions until they drain). + pub(crate) fn blocked_action_for_conversation( + &self, + conversation_id: &AIConversationId, + ) -> Option<&AIAgentAction> { + if self.running_actions.contains_key(conversation_id) { + return None; + } + self.pending_actions + .get(conversation_id) + .and_then(|queue| queue.front()) + } + + /// Returns all pending actions across all conversations. + pub(crate) fn get_pending_actions(&self) -> Vec<&AIAgentAction> { + self.pending_actions + .values() + .flat_map(|queue| queue.iter()) + .collect() + } + + /// Returns all pending actions for a specific conversation. + pub(crate) fn get_pending_actions_for_conversation( + &self, + conversation_id: &AIConversationId, + ) -> impl Iterator { + self.pending_actions + .get(conversation_id) + .into_iter() + .flat_map(|queue| queue.iter()) + } + + /// Returns a pending action by its ID, searching across all conversations. + pub(crate) fn get_pending_action_by_id( + &self, + action_id: &AIAgentActionId, + ) -> Option<&AIAgentAction> { + self.pending_actions + .values() + .flat_map(|queue| queue.iter()) + .find(|action| &action.id == action_id) + } + + /// Returns whether a conversation has any pending or running actions. + pub(crate) fn has_unfinished_actions_for_conversation( + &self, + conversation_id: AIConversationId, + ) -> bool { + let has_pending = self + .pending_actions + .get(&conversation_id) + .is_some_and(|queue| !queue.is_empty()); + let has_running = self + .running_actions + .get(&conversation_id) + .is_some_and(|running| !running.is_empty()); + has_pending || has_running + } + + /// Returns finished action results received from the most recent AI output for a conversation. + pub(crate) fn get_finished_action_results( + &self, + conversation_id: AIConversationId, + ) -> Option<&Vec>> { + self.finished_action_results.get(&conversation_id) + } + + /// Returns the result for a finished action, searching current finished results and past results. + pub(crate) fn get_action_result( + &self, + id: &AIAgentActionId, + ) -> Option<&Arc> { + self.finished_action_results + .values() + .flat_map(|results| results.iter()) + .find(|result| &result.id == id) + .or_else(|| self.past_action_results.get(id)) + } + + /// Returns the status of an action by ID. + /// + /// `is_view_only` controls whether a front-of-queue action is reported as `Blocked` + /// (interactive surfaces) or `Queued` (view-only surfaces that never block on user acceptance). + pub(crate) fn get_action_status( + &self, + id: &AIAgentActionId, + is_view_only: bool, + ) -> Option { + for (conversation_id, pending_actions_for_conversation) in &self.pending_actions { + for (index, action) in pending_actions_for_conversation.iter().enumerate() { + if &action.id != id { + continue; + } + + if index == 0 + && !is_view_only + && !self.running_actions.contains_key(conversation_id) + { + return Some(AIActionStatus::Blocked); + } + + return Some(AIActionStatus::Queued); + } + } + + self.running_actions + .values() + .find(|running| running.contains(id)) + .map(|_| AIActionStatus::RunningAsync) + .or_else(|| { + self.get_action_result(id) + .map(|result| AIActionStatus::Finished(result.clone())) + }) + .or_else(|| { + self.pending_preprocessed_actions + .values() + .any(|preprocessing| preprocessing.contains(id)) + .then_some(AIActionStatus::Preprocessing) + }) + } + + /// Bulk restores action results from a list of exchanges (used when loading conversations from tasks). + /// + /// Long-running command snapshots are downgraded to `CancelledBeforeExecution` since the command + /// was incomplete when the app was closed. + pub(crate) fn restore_action_results_from_exchanges( + &mut self, + exchanges: Vec<&AIAgentExchange>, + ) { + for exchange in exchanges.iter() { + for input in &exchange.input { + if let AIAgentInput::ActionResult { result, .. } = input { + let result_id = result.id.clone(); + let mut result_to_insert = result.clone(); + if let AIAgentActionResultType::RequestCommandOutput( + RequestCommandOutputResult::LongRunningCommandSnapshot { .. }, + ) = &result.result + { + result_to_insert.result = AIAgentActionResultType::RequestCommandOutput( + RequestCommandOutputResult::CancelledBeforeExecution, + ); + } + self.past_action_results + .insert(result_id, Arc::new(result_to_insert)); + } + } + } + } + + /// Returns the number of currently running actions (test helper). + #[cfg(any(test, feature = "integration_tests"))] + pub(crate) fn running_action_count(&self, conversation_id: AIConversationId) -> usize { + self.running_actions + .get(&conversation_id) + .map(|r| r.action_ids.len()) + .unwrap_or(0) + } + + /// Returns the number of pending (not-yet-started) actions (test helper). + #[cfg(any(test, feature = "integration_tests"))] + pub(crate) fn pending_action_count(&self, conversation_id: AIConversationId) -> usize { + self.pending_actions + .get(&conversation_id) + .map(|q| q.len()) + .unwrap_or(0) + } + + pub(crate) fn drain_finished_results( + &mut self, + conversation_id: AIConversationId, + ) -> Vec { + let action_order = self.action_order.remove(&conversation_id); + let mut finished_results = self + .finished_action_results + .remove(&conversation_id) + .unwrap_or_default(); + if let Some(action_order) = action_order { + finished_results + .sort_by_key(|result| action_order.get(&result.id).copied().unwrap_or(usize::MAX)); + } + for result in &finished_results { + self.past_action_results + .insert(result.id.clone(), result.clone()); + } + finished_results + .into_iter() + .map(|result| (*result).clone()) + .collect() + } +} diff --git a/app/src/ai/blocklist/action_model_tests.rs b/app/src/ai/blocklist/action_model_tests.rs index 01b20c037a..fdec6edb31 100644 --- a/app/src/ai/blocklist/action_model_tests.rs +++ b/app/src/ai/blocklist/action_model_tests.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; +use super::scheduler::can_start_action_with_current_phase; use super::*; use crate::ai::agent::task::TaskId; use crate::ai::agent::AIAgentActionResultType; diff --git a/app/src/ai/blocklist/mod.rs b/app/src/ai/blocklist/mod.rs index 46164a4b88..1e83f2c6d5 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -32,12 +32,18 @@ pub(crate) mod codebase_index_speedbump_banner; pub(crate) mod telemetry_banner; pub(super) mod view_util; -#[cfg_attr(target_family = "wasm", allow(unused_imports))] +#[cfg_attr( + any(target_family = "wasm", not(feature = "tui")), + allow(unused_imports) +)] pub(crate) use action_model::{ - apply_edits, read_local_file_context, BlocklistAIActionEvent, BlocklistAIActionModel, - FileReadResult, ReadFileContextResult, RequestFileEditsFormatKind, ShellCommandExecutor, - ShellCommandExecutorEvent, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, - StartAgentRequestId, + apply_edits, read_local_file_context, ActionExecution, AgentToolActionModel, + AgentToolExecutionContext, AgentToolExecutor, AgentToolScheduleHost, AgentToolScheduler, + AnyActionExecution, BlocklistAIActionEvent, BlocklistAIActionModel, ExecuteActionInput, + FileReadResult, PreprocessActionInput, ReadFileContextResult, RequestFileEditsFormatKind, + RunningActionPhase, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, + StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId, SurfaceSpecificToolExecutor, + TryExecuteResult, }; pub(crate) use agent_conversation_engine::AgentConversationEngine; #[cfg(feature = "tui")] diff --git a/app/src/lib.rs b/app/src/lib.rs index f5f041a448..50f6afc05a 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -1919,6 +1919,7 @@ pub(crate) fn initialize_app( ); #[cfg(feature = "tui")] if matches!(launch_mode, LaunchMode::Tui { .. }) { + ctx.add_singleton_model(crate::tui::TuiToolActionModel::new); ctx.add_singleton_model(crate::tui::CoreTuiModel::new); } diff --git a/app/src/tui.rs b/app/src/tui.rs index c65d7d0537..a74d96638b 100644 --- a/app/src/tui.rs +++ b/app/src/tui.rs @@ -8,6 +8,7 @@ //! quit is handled by the runtime's input loop. mod input_view; +mod tool_model; mod transcript_view; use std::collections::HashMap; @@ -21,6 +22,7 @@ use ai::skills::SkillPathOrigin; use anyhow::{anyhow, Result}; use chrono::Local; use input_view::{InputEvent, TuiInputView}; +pub(crate) use tool_model::{TuiToolActionEvent, TuiToolActionModel}; use transcript_view::TuiTranscriptView; use warp_multi_agent_api::{AgentType, ToolType}; use warpui::r#async::Timer; @@ -38,8 +40,8 @@ use crate::ai::agent::api::{self, RequestParams}; use crate::ai::agent::conversation::{AIConversation, AIConversationId}; use crate::ai::agent::task::TaskId; use crate::ai::agent::{ - AIAgentAttachment, AIAgentContext, AIAgentInput, AIAgentOutputStatus, CancellationReason, - FinishedAIAgentOutput, UserQueryMode, + AIAgentAction, AIAgentActionResult, AIAgentAttachment, AIAgentContext, AIAgentInput, + AIAgentOutputStatus, CancellationReason, FinishedAIAgentOutput, UserQueryMode, }; use crate::ai::blocklist::{ AgentConversationEngine, AgentConversationEngineDelegate, AgentSessionOwnerId, @@ -53,6 +55,20 @@ use crate::ai_assistant::execution_context::{WarpAiExecutionContext, WarpAiOsCon /// border (top + bottom), i.e. three rows total. const INPUT_ROWS: u16 = 3; +/// The agent tools the v0 TUI can meaningfully execute. The long-running-command +/// follow-up tools (read/write/transfer) are intentionally excluded until the TUI +/// has a command registry; see the LRC follow-up. +fn tui_supported_tools() -> Vec { + vec![ + ToolType::RunShellCommand, + ToolType::ApplyFileDiffs, + ToolType::ReadFiles, + ToolType::Grep, + ToolType::FileGlob, + ToolType::FileGlobV2, + ] +} + /// App-level singleton owning the TUI app's single agent session. pub struct CoreTuiModel { owner: Option, @@ -72,6 +88,26 @@ impl CoreTuiModel { ctx.emit(CoreTuiModelEvent::ConversationUpdated { conversation_id }); } }); + ctx.subscribe_to_model( + &TuiToolActionModel::handle(ctx), + |me, event, ctx| match event { + TuiToolActionEvent::Updated { conversation_id } => { + if Some(*conversation_id) == me.active_conversation_id { + ctx.emit(CoreTuiModelEvent::ConversationUpdated { + conversation_id: *conversation_id, + }); + } + } + TuiToolActionEvent::ActionsFinished { conversation_id } => { + if Some(*conversation_id) != me.active_conversation_id { + return; + } + if let Err(error) = me.send_action_results(*conversation_id, ctx) { + log::error!("failed to send TUI tool-result follow-up: {error:#}"); + } + } + }, + ); Self { owner: None, @@ -119,7 +155,7 @@ impl CoreTuiModel { let conversation_id = self.active_conversation_id.unwrap_or_else(|| { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { - history_model.start_new_conversation(owner.entity_id(), false, false, false, ctx) + history_model.start_new_conversation(owner.entity_id(), true, false, false, ctx) }) }); @@ -176,6 +212,54 @@ impl CoreTuiModel { Ok((conversation_id, response_stream_id)) } + fn send_action_results( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) -> Result<(AIConversationId, ResponseStreamId)> { + if self.in_flight.is_some() { + return Err(anyhow!("TUI agent request already in flight")); + } + let owner = self + .owner + .ok_or_else(|| anyhow!("TUI agent session is not registered"))?; + let finished_results = TuiToolActionModel::handle(ctx).update(ctx, |model, _| { + model.drain_finished_results(conversation_id) + }); + if finished_results.is_empty() { + return Err(anyhow!("TUI tool result follow-up had no finished results")); + } + + let (_task_id, conversation_data, parent_agent_id, agent_name) = + conversation_request_data(owner, conversation_id, ctx)?; + let context = TuiAgentContextBuilder::context(ctx); + let request_input = + tui_action_result_request_input(owner, conversation_id, finished_results, context, ctx); + let mut request_params = RequestParams::new( + Some(owner.entity_id()), + TuiAgentContextBuilder::session_context(ctx), + &request_input, + conversation_data.clone(), + None, + ctx, + ); + request_params.parent_agent_id = parent_agent_id; + request_params.agent_name = agent_name; + + let (response_stream, response_stream_id) = AgentConversationEngine::send_request( + owner, + request_input, + request_params, + conversation_data, + /*can_attempt_resume_on_error*/ true, + ctx, + ); + self.active_conversation_id = Some(conversation_id); + self.in_flight = Some(response_stream); + ctx.emit(CoreTuiModelEvent::PromptSubmitted { conversation_id }); + Ok((conversation_id, response_stream_id)) + } + /// Cancels the active TUI request, if any. pub fn cancel_active_request(&mut self, ctx: &mut ModelContext) { let (Some(response_stream), Some(conversation_id)) = @@ -299,6 +383,17 @@ impl AgentConversationEngineDelegate for CoreTuiModel { SkillPathOrigin::Local } + fn queue_client_actions( + &mut self, + actions: Vec, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) { + TuiToolActionModel::handle(ctx).update(ctx, |model, ctx| { + model.queue_actions(actions, conversation_id, ctx); + }); + } + fn finished_receiving_output( &mut self, stream_id: ResponseStreamId, @@ -655,7 +750,54 @@ fn tui_request_input( .clone(), shared_session_response_initiator: None, request_start_ts: Local::now(), - supported_tools_override: Some(Vec::::new()), + supported_tools_override: Some(tui_supported_tools()), + } +} + +fn tui_action_result_request_input( + owner: AgentSessionOwnerId, + conversation_id: AIConversationId, + action_results: Vec, + context: Arc<[AIAgentContext]>, + app: &AppContext, +) -> RequestInput { + let llm_prefs = LLMPreferences::as_ref(app); + let mut input_messages: HashMap> = HashMap::new(); + for result in action_results { + input_messages + .entry(result.task_id.clone()) + .or_default() + .push(AIAgentInput::ActionResult { + result, + context: context.clone(), + }); + } + + RequestInput { + conversation_id, + input_messages, + working_directory: TuiAgentContextBuilder::session_context(app) + .current_working_directory() + .clone(), + model_id: llm_prefs + .get_active_base_model(app, Some(owner.entity_id())) + .id + .clone(), + coding_model_id: llm_prefs + .get_active_coding_model(app, Some(owner.entity_id())) + .id + .clone(), + cli_agent_model_id: llm_prefs + .get_active_cli_agent_model(app, Some(owner.entity_id())) + .id + .clone(), + computer_use_model_id: llm_prefs + .get_active_computer_use_model(app, Some(owner.entity_id())) + .id + .clone(), + shared_session_response_initiator: None, + request_start_ts: Local::now(), + supported_tools_override: Some(tui_supported_tools()), } } diff --git a/app/src/tui/tool_model.rs b/app/src/tui/tool_model.rs new file mode 100644 index 0000000000..69569d7a6b --- /dev/null +++ b/app/src/tui/tool_model.rs @@ -0,0 +1,890 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use ai::diff_validation::{AIRequestedCodeDiff, DiffType}; +use anyhow::{anyhow, Result}; +use chrono::Local; +use futures::future::{join_all, BoxFuture}; +use futures::FutureExt; +use warp_core::command::ExitCode; +use warp_terminal::model::BlockId; +use warp_util::path::ShellFamily; +use warpui::{AppContext, Entity, ModelContext, SingletonEntity}; + +use crate::ai::agent::conversation::AIConversationId; +use crate::ai::agent::{ + AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, + AIAgentActionType, AnyFileContent, CancellationReason, FileContext, FileGlobResult, + FileGlobV2Result, GrepResult, ReadFilesResult, ReadShellCommandOutputResult, + RequestCommandOutputResult, RequestFileEditsResult, TransferShellCommandControlToUserResult, + UpdatedFileContext, WriteToLongRunningShellCommandResult, +}; +use crate::ai::blocklist::{ + apply_edits, ActionExecution, AgentToolActionModel, AgentToolExecutionContext, + AgentToolExecutor, AgentToolScheduleHost, AgentToolScheduler, AnyActionExecution, + BlocklistAIPermissions, ExecuteActionInput, FileReadResult, PreprocessActionInput, + RunningActionPhase, SessionContext, SurfaceSpecificToolExecutor, TryExecuteResult, +}; +use crate::ai::paths::host_native_absolute_path; +use crate::auth::AuthStateProvider; +use crate::terminal::model::session::{ + BootstrapSessionType, ExecuteCommandOptions, HostInfo, IsSSHWrapperSession, + LocalCommandExecutor, Session, SessionInfo, +}; +use crate::terminal::shell::{Shell, ShellLaunchData, ShellType}; +use crate::AuthState; + +/// Minimal card data for TUI tool rendering. +#[derive(Clone, Debug)] +pub(crate) struct TuiToolCard { + pub action_id: AIAgentActionId, + pub title: String, + pub lines: Vec, +} + +/// Minimal TUI-owned wrapper around shared tool action state and execution. +pub(crate) struct TuiToolActionModel { + tools: AgentToolActionModel, + cards_by_conversation: HashMap>, + /// Stores the action type for each in-flight action so `on_action_finished` can build the + /// result card without re-fetching from pending (which has been drained by then). + pending_action_types: HashMap, + session: Arc, +} + +pub(crate) enum TuiToolActionEvent { + Updated { conversation_id: AIConversationId }, + ActionsFinished { conversation_id: AIConversationId }, +} + +impl TuiToolActionModel { + pub fn new(_: &mut ModelContext) -> Self { + Self { + tools: AgentToolActionModel::new(), + cards_by_conversation: HashMap::new(), + pending_action_types: HashMap::new(), + session: Arc::new(tui_local_session()), + } + } + + pub fn card_for_action( + &self, + conversation_id: AIConversationId, + action_id: &AIAgentActionId, + ) -> Option<&TuiToolCard> { + self.cards_by_conversation + .get(&conversation_id) + .and_then(|cards| cards.iter().find(|card| &card.action_id == action_id)) + } + + pub fn drain_finished_results( + &mut self, + conversation_id: AIConversationId, + ) -> Vec { + self.tools.drain_finished_results(conversation_id) + } + + pub fn queue_actions( + &mut self, + actions: Vec, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) { + if actions.is_empty() { + return; + } + AgentToolScheduler::queue_actions(self, actions, conversation_id, ctx); + } + + /// Updates or inserts the rendered card for an action. + fn update_card(&mut self, conversation_id: AIConversationId, card: TuiToolCard) { + let cards = self + .cards_by_conversation + .entry(conversation_id) + .or_default(); + if let Some(existing) = cards + .iter_mut() + .find(|existing| existing.action_id == card.action_id) + { + *existing = card; + } else { + cards.push(card); + } + } +} + +impl Entity for TuiToolActionModel { + type Event = TuiToolActionEvent; +} + +impl SingletonEntity for TuiToolActionModel {} + +impl AgentToolScheduleHost for TuiToolActionModel { + type Context<'a> = ModelContext<'a, Self>; + + fn app_context<'a, 'b>(ctx: &'a Self::Context<'b>) -> &'a AppContext { + ctx + } + + fn tools(&mut self) -> &mut AgentToolActionModel { + &mut self.tools + } + + fn tools_ref(&self) -> &AgentToolActionModel { + &self.tools + } + + fn preprocess( + &mut self, + _action: &AIAgentAction, + _conversation_id: AIConversationId, + _ctx: &mut Self::Context<'_>, + ) -> BoxFuture<'static, ()> { + futures::future::ready(()).boxed() + } + + fn try_execute( + &mut self, + action: AIAgentAction, + conversation_id: AIConversationId, + _is_user_initiated: bool, + ctx: &mut Self::Context<'_>, + ) -> TryExecuteResult { + let mut surface = TuiToolExecutor::new(self.session.clone(), ctx); + let input = ExecuteActionInput { + action: &action, + conversation_id, + }; + let can_auto_execute = AgentToolExecutor::should_autoexecute(&mut surface, input, ctx); + if !can_auto_execute { + // v0 autonomous policy: the only denials are denylisted commands and + // protected-path writes. Report the denial so the agent can adapt rather + // than silently running or hanging. + // TODO: when the TUI gains real (supervised) permissions + an approval + // surface, route policy-denied actions to that surface instead of + // returning a denial here, and stop forcing is_autoexecute_override. + let result = match &action.action { + AIAgentActionType::RequestCommandOutput { command, .. } => { + AIAgentActionResultType::RequestCommandOutput( + RequestCommandOutputResult::Denylisted { + command: command.clone(), + }, + ) + } + AIAgentActionType::RequestFileEdits { .. } => { + AIAgentActionResultType::RequestFileEdits( + RequestFileEditsResult::DiffApplicationFailed { + error: "File edit was not permitted by the current autonomy policy." + .to_string(), + }, + ) + } + _ => action.action.cancelled_result(), + }; + let r = Arc::new(AIAgentActionResult { + id: action.id.clone(), + task_id: action.task_id.clone(), + result, + }); + ctx.spawn(futures::future::ready(()), move |model, _, ctx| { + AgentToolScheduler::finish_action(model, conversation_id, r, None, ctx); + }); + return TryExecuteResult::ExecutedAsync; + } + let execution = AgentToolExecutor::execute_action(&mut surface, input, ctx); + let action_id = action.id.clone(); + let task_id = action.task_id.clone(); + match execution { + AnyActionExecution::Async { + execute_future, + on_complete, + } => { + ctx.spawn(execute_future, move |model, result, ctx| { + let r = Arc::new(AIAgentActionResult { + id: action_id, + task_id, + result: on_complete(result, ctx), + }); + AgentToolScheduler::finish_action(model, conversation_id, r, None, ctx); + }); + TryExecuteResult::ExecutedAsync + } + AnyActionExecution::Sync(result) => { + let r = Arc::new(AIAgentActionResult { + id: action_id, + task_id, + result, + }); + // Defer via a ready future to avoid re-entrant scheduling. + ctx.spawn(futures::future::ready(()), move |model, _, ctx| { + AgentToolScheduler::finish_action(model, conversation_id, r, None, ctx); + }); + TryExecuteResult::ExecutedAsync + } + AnyActionExecution::NotReady | AnyActionExecution::InvalidAction => { + let r = Arc::new(AIAgentActionResult { + id: action_id, + task_id, + result: action.action.cancelled_result(), + }); + // Defer via a ready future to avoid re-entrant scheduling. + ctx.spawn(futures::future::ready(()), move |model, _, ctx| { + AgentToolScheduler::finish_action(model, conversation_id, r, None, ctx); + }); + TryExecuteResult::ExecutedAsync + } + } + } + + fn can_autoexecute( + &mut self, + action: &AIAgentAction, + conversation_id: AIConversationId, + ctx: &mut Self::Context<'_>, + ) -> bool { + let mut surface = TuiToolExecutor::new(self.session.clone(), ctx); + let input = ExecuteActionInput { + action, + conversation_id, + }; + AgentToolExecutor::should_autoexecute(&mut surface, input, ctx) + } + + fn action_phase(&self, action: &AIAgentAction, ctx: &AppContext) -> RunningActionPhase { + let surface = TuiToolExecutor::for_phase_check(self.session.clone(), ctx); + AgentToolExecutor::action_phase(&surface, action, ctx) + } + + fn spawn_after_preprocess( + &mut self, + futures: Vec>, + ctx: &mut Self::Context<'_>, + then: impl FnOnce(&mut Self, &mut Self::Context<'_>) + 'static, + ) { + ctx.spawn(join_all(futures), move |model, _, ctx| then(model, ctx)); + } + + fn on_action_enqueued( + &mut self, + conversation_id: AIConversationId, + action_id: &AIAgentActionId, + ctx: &mut Self::Context<'_>, + ) { + // Look up the action type (it was just pushed to pending_actions by the scheduler). + let title = if let Some(action) = self.tools.find_pending_action(conversation_id, action_id) + { + self.pending_action_types + .insert(action_id.clone(), action.action.clone()); + action.action.user_friendly_name() + } else { + String::new() + }; + self.cards_by_conversation + .entry(conversation_id) + .or_default() + .push(TuiToolCard { + action_id: action_id.clone(), + title, + lines: vec!["queued".to_string()], + }); + ctx.emit(TuiToolActionEvent::Updated { conversation_id }); + } + + fn on_action_finished( + &mut self, + conversation_id: AIConversationId, + result: &Arc, + _cancellation_reason: Option, + ctx: &mut Self::Context<'_>, + ) { + let action_type = self + .pending_action_types + .remove(&result.id) + .unwrap_or(AIAgentActionType::InitProject); + let card = card_for_result(result.id.clone(), &action_type, &result.result); + self.update_card(conversation_id, card); + ctx.emit(TuiToolActionEvent::Updated { conversation_id }); + } + + fn on_phase_drained( + &mut self, + conversation_id: AIConversationId, + _cancellation_reason: Option, + ctx: &mut Self::Context<'_>, + ) { + ctx.emit(TuiToolActionEvent::ActionsFinished { conversation_id }); + } +} + +struct TuiToolExecutor { + session: Arc, + current_working_directory: Option, + session_context: SessionContext, + shell_type: ShellType, + shell_launch_data: Option, + background_executor: Arc, + auth_state: Arc, +} + +impl TuiToolExecutor { + /// Creates a surface-specific executor for one TUI action execution pass. + fn new(session: Arc, ctx: &mut ModelContext) -> Self { + let current_working_directory = std::env::current_dir() + .ok() + .map(|path| path.to_string_lossy().to_string()); + Self { + session, + current_working_directory: current_working_directory.clone(), + session_context: SessionContext::local(current_working_directory), + shell_type: shell_type_from_env(), + shell_launch_data: None, + background_executor: ctx.background_executor(), + auth_state: AuthStateProvider::as_ref(ctx).get().clone(), + } + } + + /// Lightweight executor for action-phase queries that only need session info. + fn for_phase_check(session: Arc, ctx: &AppContext) -> Self { + let current_working_directory = std::env::current_dir() + .ok() + .map(|path| path.to_string_lossy().to_string()); + Self { + session, + current_working_directory: current_working_directory.clone(), + session_context: SessionContext::local(current_working_directory), + shell_type: shell_type_from_env(), + shell_launch_data: None, + background_executor: ctx.background_executor().clone(), + auth_state: AuthStateProvider::as_ref(ctx).get().clone(), + } + } +} + +impl SurfaceSpecificToolExecutor for TuiToolExecutor { + type Context<'a> = ModelContext<'a, TuiToolActionModel>; + + fn tool_execution_context(&self, _ctx: &Self::Context<'_>) -> AgentToolExecutionContext { + self.execution_context() + } + + fn tool_execution_context_from_app(&self, _ctx: &AppContext) -> AgentToolExecutionContext { + self.execution_context() + } + + fn app_context<'a, 'b>(ctx: &'a Self::Context<'b>) -> &'a AppContext { + ctx + } + + fn preprocess_shell( + &mut self, + _input: PreprocessActionInput<'_>, + _ctx: &mut Self::Context<'_>, + ) -> BoxFuture<'static, ()> { + futures::future::ready(()).boxed() + } + + fn execute_shell( + &mut self, + input: ExecuteActionInput<'_>, + _ctx: &mut Self::Context<'_>, + ) -> AnyActionExecution { + match &input.action.action { + AIAgentActionType::RequestCommandOutput { + command, + wait_until_completion, + uses_pager, + .. + } => { + let command = command.clone(); + let current_working_directory = self.current_working_directory.clone(); + let shell_type = self.shell_type; + let session = self.session.clone(); + let wait_until_completion = *wait_until_completion; + let uses_pager = *uses_pager; + ActionExecution::new_async( + async move { + execute_command( + command, + wait_until_completion, + uses_pager, + current_working_directory, + shell_type, + session, + ) + .await + }, + |result, _ctx| { + AIAgentActionResultType::RequestCommandOutput(result.unwrap_or_else( + |error| RequestCommandOutputResult::Completed { + block_id: BlockId::new(), + command: "".to_string(), + output: format!("{error:#}"), + exit_code: ExitCode::from(1), + start_ts: Some(Local::now()), + completed_ts: Some(Local::now()), + }, + )) + }, + ) + .into() + } + AIAgentActionType::ReadShellCommandOutput { .. } => { + ActionExecution::<()>::Sync(AIAgentActionResultType::ReadShellCommandOutput( + ReadShellCommandOutputResult::Error( + crate::ai::agent::ShellCommandError::BlockNotFound, + ), + )) + .into() + } + AIAgentActionType::WriteToLongRunningShellCommand { .. } => { + ActionExecution::<()>::Sync( + AIAgentActionResultType::WriteToLongRunningShellCommand( + WriteToLongRunningShellCommandResult::Error( + crate::ai::agent::ShellCommandError::BlockNotFound, + ), + ), + ) + .into() + } + AIAgentActionType::TransferShellCommandControlToUser { .. } => { + ActionExecution::<()>::Sync( + AIAgentActionResultType::TransferShellCommandControlToUser( + TransferShellCommandControlToUserResult::Error( + crate::ai::agent::ShellCommandError::BlockNotFound, + ), + ), + ) + .into() + } + _ => ActionExecution::<()>::InvalidAction.into(), + } + } + + fn should_autoexecute_shell( + &mut self, + input: ExecuteActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> bool { + let AIAgentActionType::RequestCommandOutput { + command, + is_read_only, + is_risky, + .. + } = &input.action.action + else { + return false; + }; + let escape_char = ShellFamily::from(self.shell_type).escape_char(); + BlocklistAIPermissions::as_ref(ctx) + .can_autoexecute_command( + &input.conversation_id, + command, + escape_char, + is_read_only.unwrap_or(false), + *is_risky, + None, // TUI has no terminal view; resolves to the default profile. + ctx, + ) + .is_allowed() + } + + fn preprocess_file_edits( + &mut self, + _input: PreprocessActionInput<'_>, + _ctx: &mut Self::Context<'_>, + ) -> BoxFuture<'static, ()> { + futures::future::ready(()).boxed() + } + + fn execute_file_edits( + &mut self, + input: ExecuteActionInput<'_>, + _ctx: &mut Self::Context<'_>, + ) -> AnyActionExecution { + let AIAgentActionType::RequestFileEdits { file_edits, .. } = &input.action.action else { + return ActionExecution::<()>::InvalidAction.into(); + }; + let file_edits = file_edits.clone(); + let session_context = self.session_context.clone(); + let background_executor = self.background_executor.clone(); + let auth_state = self.auth_state.clone(); + ActionExecution::new_async( + async move { + execute_file_edits(file_edits, session_context, background_executor, auth_state) + .await + }, + |result, _ctx| { + AIAgentActionResultType::RequestFileEdits(result.unwrap_or_else(|error| { + RequestFileEditsResult::DiffApplicationFailed { + error: format!("{error:#}"), + } + })) + }, + ) + .into() + } + + fn should_autoexecute_file_edits( + &mut self, + input: ExecuteActionInput<'_>, + ctx: &mut Self::Context<'_>, + ) -> bool { + let AIAgentActionType::RequestFileEdits { file_edits, .. } = &input.action.action else { + return false; + }; + let paths: Vec = file_edits + .iter() + .filter_map(|edit| edit.file()) + .map(|name| { + std::path::PathBuf::from(host_native_absolute_path( + name, + &self.shell_launch_data, + &self.current_working_directory, + )) + }) + .collect(); + BlocklistAIPermissions::as_ref(ctx) + .can_write_files(&input.conversation_id, &paths, None, ctx) + .is_allowed() + } +} + +impl TuiToolExecutor { + /// Builds the shared execution context for surface-neutral tools. + fn execution_context(&self) -> AgentToolExecutionContext { + AgentToolExecutionContext { + current_working_directory: self.current_working_directory.clone(), + shell_launch_data: self.shell_launch_data.clone(), + session: Some(self.session.clone()), + terminal_view_id: None, + } + } +} + +/// Executes a TUI shell command through the local session. +async fn execute_command( + command: String, + wait_until_completion: bool, + uses_pager: Option, + current_working_directory: Option, + shell_type: ShellType, + session: Arc, +) -> Result { + let command = if uses_pager == Some(true) && wait_until_completion { + decorate_pager_command(&command, shell_type) + } else { + command + }; + let block_id = BlockId::new(); + let start_ts = Local::now(); + let output = session + .execute_command( + &command, + current_working_directory.as_deref(), + None, + ExecuteCommandOptions::default(), + ) + .await?; + let completed_ts = Local::now(); + let mut combined = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.is_empty() { + if !combined.is_empty() && !combined.ends_with('\n') { + combined.push('\n'); + } + combined.push_str(&stderr); + } + Ok(RequestCommandOutputResult::Completed { + block_id, + command, + output: combined, + exit_code: output.exit_code().unwrap_or_else(|| ExitCode::from(1)), + start_ts: Some(start_ts), + completed_ts: Some(completed_ts), + }) +} + +/// Applies and saves TUI file edits automatically for v0. +async fn execute_file_edits( + file_edits: Vec, + session_context: SessionContext, + background_executor: Arc, + auth_state: Arc, +) -> Result { + let diffs = apply_edits( + file_edits, + &session_context, + &Default::default(), + background_executor, + auth_state, + false, + |path| async move { FileReadResult::from(std::fs::read_to_string(path)) }, + ) + .await + .map_err(|errors| { + anyhow!(errors + .iter() + .map(|error| format!("{error:?}")) + .collect::>() + .join("\n")) + })?; + + apply_requested_diffs(diffs, &session_context).await +} + +/// Saves requested code diffs and builds the action result payload. +async fn apply_requested_diffs( + diffs: Vec, + session_context: &SessionContext, +) -> Result { + let mut unified = String::new(); + let mut updated_files = Vec::new(); + let mut deleted_files = Vec::new(); + let mut lines_added = 0usize; + let mut lines_removed = 0usize; + + for diff in diffs { + let absolute_path = host_native_absolute_path( + &diff.file_name, + session_context.shell(), + session_context.current_working_directory(), + ); + let path = PathBuf::from(&absolute_path); + let (new_content, deleted, added, removed) = apply_diff_to_content(&diff)?; + lines_added += added; + lines_removed += removed; + unified.push_str(&format!( + "--- {}\n+++ {}\n@@\n{}\n", + diff.file_name, diff.file_name, new_content + )); + if deleted { + if path.exists() { + async_fs::remove_file(&path).await?; + } + deleted_files.push(diff.file_name); + continue; + } + if let Some(parent) = path.parent() { + async_fs::create_dir_all(parent).await?; + } + async_fs::write(&path, new_content.as_bytes()).await?; + updated_files.push(UpdatedFileContext { + was_edited_by_user: false, + file_context: FileContext::new( + diff.file_name, + AnyFileContent::StringContent(new_content), + None, + None, + ), + }); + } + + Ok(RequestFileEditsResult::Success { + diff: unified, + updated_files, + deleted_files, + lines_added, + lines_removed, + }) +} + +/// Applies one requested diff to its original content. +fn apply_diff_to_content(diff: &AIRequestedCodeDiff) -> Result<(String, bool, usize, usize)> { + match &diff.diff_type { + DiffType::Create { delta } => Ok(( + delta.insertion.clone(), + false, + delta.insertion.lines().count(), + 0, + )), + DiffType::Delete { delta } => { + Ok((String::new(), true, 0, delta.replacement_line_range.len())) + } + DiffType::Update { deltas, .. } => { + let had_trailing_newline = diff.original_content.ends_with('\n'); + let mut lines = diff + .original_content + .lines() + .map(str::to_string) + .collect::>(); + let mut added = 0usize; + let mut removed = 0usize; + for delta in deltas.iter().rev() { + let start = delta + .replacement_line_range + .start + .saturating_sub(1) + .min(lines.len()); + let end = delta + .replacement_line_range + .end + .saturating_sub(1) + .min(lines.len()); + let replacement = delta + .insertion + .lines() + .map(str::to_string) + .collect::>(); + added += replacement.len(); + removed += end.saturating_sub(start); + lines.splice(start..end, replacement); + } + let mut content = lines.join("\n"); + if had_trailing_newline { + content.push('\n'); + } + Ok((content, false, added, removed)) + } + } +} + +/// Builds a concise TUI card from a tool result. +fn card_for_result( + action_id: AIAgentActionId, + action: &AIAgentActionType, + result: &AIAgentActionResultType, +) -> TuiToolCard { + let mut lines = Vec::new(); + match result { + AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Completed { + command, + output, + exit_code, + .. + }) => { + lines.push(command.clone()); + lines.push(format!( + "exit {} · {} lines captured", + exit_code.value(), + output.lines().count() + )); + } + AIAgentActionResultType::RequestFileEdits(RequestFileEditsResult::Success { + updated_files, + lines_added, + lines_removed, + .. + }) => { + lines.push( + updated_files + .iter() + .map(|file| file.file_context.file_name.as_str()) + .collect::>() + .join(", "), + ); + lines.push(format!( + "+{lines_added} -{lines_removed} · applied automatically" + )); + } + AIAgentActionResultType::ReadFiles(ReadFilesResult::Success { files }) => { + lines.push(format!("read {} file(s)", files.len())); + } + AIAgentActionResultType::ReadFiles(ReadFilesResult::Error(e)) => { + lines.push(format!("error: {}", e.lines().next().unwrap_or("unknown"))); + } + AIAgentActionResultType::ReadFiles(ReadFilesResult::Cancelled) => { + lines.push("cancelled".to_string()); + } + AIAgentActionResultType::Grep(GrepResult::Success { matched_files }) => { + lines.push(format!("{} file(s) matched", matched_files.len())); + } + AIAgentActionResultType::Grep(GrepResult::Error(e)) => { + lines.push(format!("error: {}", e.lines().next().unwrap_or("unknown"))); + } + AIAgentActionResultType::Grep(GrepResult::Cancelled) => { + lines.push("cancelled".to_string()); + } + AIAgentActionResultType::FileGlob(FileGlobResult::Success { matched_files }) => { + let count = if matched_files.trim().is_empty() { + 0 + } else { + matched_files.lines().count() + }; + lines.push(format!("{count} file(s) matched")); + } + AIAgentActionResultType::FileGlob(FileGlobResult::Error(e)) => { + lines.push(format!("error: {}", e.lines().next().unwrap_or("unknown"))); + } + AIAgentActionResultType::FileGlob(FileGlobResult::Cancelled) => { + lines.push("cancelled".to_string()); + } + AIAgentActionResultType::FileGlobV2(FileGlobV2Result::Success { + matched_files, .. + }) => { + lines.push(format!("{} file(s) matched", matched_files.len())); + } + AIAgentActionResultType::FileGlobV2(FileGlobV2Result::Error(e)) => { + lines.push(format!("error: {}", e.lines().next().unwrap_or("unknown"))); + } + AIAgentActionResultType::FileGlobV2(FileGlobV2Result::Cancelled) => { + lines.push("cancelled".to_string()); + } + other => { + lines.push(other.to_string()); + } + } + TuiToolCard { + action_id, + title: format!("Tool: {}", action.user_friendly_name()), + lines, + } +} + +/// Decorates pager commands so they do not take over the terminal UI. +fn decorate_pager_command(command: &str, shell_type: ShellType) -> String { + match shell_type { + ShellType::Zsh | ShellType::Bash => format!("({command}) | command cat"), + ShellType::Fish => format!("begin; {command}; end | command cat"), + ShellType::PowerShell => format!("({command}) | \\Out-Host"), + } +} + +/// Detects the user's shell type from the process environment. +fn shell_type_from_env() -> ShellType { + std::env::var("SHELL") + .ok() + .as_deref() + .and_then(ShellType::from_name) + .unwrap_or(ShellType::Zsh) +} + +/// Creates the single local session used by the v0 TUI. +fn tui_local_session() -> Session { + let shell_path = std::env::var("SHELL").ok(); + let shell_type = shell_type_from_env(); + let command_executor = Arc::new(LocalCommandExecutor::new( + shell_path.as_ref().map(PathBuf::from), + shell_type, + )); + Session::new( + SessionInfo { + session_id: 0.into(), + shell: Shell::new(shell_type, None, None, Default::default(), shell_path), + launch_data: None, + histfile: None, + user: "local:user".to_owned(), + hostname: "local:host".to_owned(), + subshell_info: None, + path: std::env::var("PATH").ok(), + environment_variable_names: Default::default(), + aliases: Default::default(), + abbreviations: Default::default(), + function_names: Default::default(), + builtins: Default::default(), + keywords: Default::default(), + is_ssh_wrapper_session: IsSSHWrapperSession::No, + home_dir: dirs::home_dir().map(|path| path.to_string_lossy().to_string()), + cdpath: None, + editor: None, + session_type: BootstrapSessionType::Local, + host_info: HostInfo { + os_category: Some(std::env::consts::OS.to_string()), + linux_distribution: None, + }, + wsl_name: None, + spawning_session_id: None, + }, + command_executor, + ) +} diff --git a/app/src/tui/transcript_view.rs b/app/src/tui/transcript_view.rs index b53b0c1c9b..7fb72c112b 100644 --- a/app/src/tui/transcript_view.rs +++ b/app/src/tui/transcript_view.rs @@ -7,7 +7,7 @@ use warpui::SingletonEntity; use warpui_core::elements::tui::{Color, Modifier, TuiColumn, TuiElement, TuiStyle, TuiText}; use warpui_core::{AppContext, Entity, TuiView}; -use super::CoreTuiModel; +use super::{CoreTuiModel, TuiToolActionModel}; use crate::ai::blocklist::BlocklistAIHistoryModel; /// Near-white transcript text (`#f1f1f1`). @@ -53,6 +53,22 @@ impl TuiView for TuiTranscriptView { if has_output { children.push(Box::new(TuiText::new(output).with_style(agent_style))); } + if let Some(output) = exchange.output_status.output() { + for action in output.get().actions() { + if let Some(card) = + TuiToolActionModel::as_ref(ctx).card_for_action(conversation_id, &action.id) + { + children.push(Box::new( + TuiText::new(format!("[ {} ]", card.title)).with_style(agent_style), + )); + for line in &card.lines { + children.push(Box::new( + TuiText::new(format!(" {line}")).with_style(agent_style), + )); + } + } + } + } if has_input || has_output { children.push(Box::new(TuiText::new(" "))); } diff --git a/app/src/tui_tests.rs b/app/src/tui_tests.rs index 344891c153..f5d9910677 100644 --- a/app/src/tui_tests.rs +++ b/app/src/tui_tests.rs @@ -525,7 +525,7 @@ fn core_tui_model_sends_initial_prompt_and_follow_up() { assert_eq!(first_params.input.len(), 1); assert_eq!(first_params.conversation_token, None); assert_eq!(first_params.tasks.len(), 0); - assert_eq!(first_params.supported_tools_override, Some(vec![])); + assert_eq!(first_params.supported_tools_override, None); complete_initial_fake_stream(&mut app, owner, conversation_id, &first_stream_id); @@ -563,7 +563,7 @@ fn core_tui_model_sends_initial_prompt_and_follow_up() { !second_params.tasks.is_empty(), "follow-up should carry prior task context", ); - assert_eq!(second_params.supported_tools_override, Some(vec![])); + assert_eq!(second_params.supported_tools_override, None); complete_follow_up_fake_stream(&mut app, owner, conversation_id, &_second_stream_id); diff --git a/specs/tui-toolcalling/DECISIONS.md b/specs/tui-toolcalling/DECISIONS.md new file mode 100644 index 0000000000..5d6d8f9e51 --- /dev/null +++ b/specs/tui-toolcalling/DECISIONS.md @@ -0,0 +1,337 @@ +# TUI tool calling architectural decisions + +This document records the important architectural decisions made while adding tool execution to the `warp-tui` prototype. + +## 1. Use a shared-first tool executor, not separate GUI and TUI dispatch trees + +### Decision + +Both GUI and TUI tool execution enter the same shared `AgentToolExecutor`. + +`AgentToolExecutor` owns the top-level `AIAgentActionType` dispatch: + +- shared tools are handled directly by `AgentToolExecutor`; +- inherently surface-specific tools are delegated to a required `SurfaceSpecificToolExecutor` implementation. + +### Why + +The first TUI implementation duplicated a top-level `match AIAgentActionType`. + +Moving that match into a TUI-looking shared file did not solve the problem because GUI still used `BlocklistAIActionExecutor`'s separate dispatch. The central issue was not just code location; it was that adding a new tool could still require adding one branch for GUI and another branch for TUI. + +### Alternatives considered + +- **Keep TUI-specific execution in `app/src/tui/tool_model.rs`.** + Rejected because it duplicated GUI controller/executor logic and would not scale as more tools were added. +- **Move TUI execution into `basic_tool_executor.rs`.** + Rejected because only TUI used it, so it was shared-looking but not actually shared. +- **Use an optional override registry keyed by tool family.** + Rejected because it was too framework-like for only two surfaces and made specialization feel optional when some tools require explicit GUI and TUI decisions. + +## 2. Make shared execution the default and surface-specific execution explicit + +### Decision + +`AgentToolExecutor` handles tools directly when the implementation can be surface-neutral. + +`SurfaceSpecificToolExecutor` is required only for tool families that cannot be implemented correctly without surface-specific state. + +### Why + +The desired default is that a reusable tool is implemented once and automatically works in GUI and TUI. + +If a tool truly depends on terminal blocks, GUI views, TUI process state, or approval UI, both surfaces should be forced to make an explicit implementation decision. + +### Alternatives considered + +- **Make GUI and TUI each register optional overrides.** + Rejected because, with only GUI and TUI surfaces, if one surface needs specialization then the other usually needs to make an explicit decision too. +- **Make every tool a backend method.** + Rejected because it would preserve per-surface duplication and make shared execution harder to see. + +## 3. Share `read_files`, `grep`, and `file_glob` execution now + +### Decision + +These tool families are implemented as shared `AgentToolExecutor` defaults: + +- `ReadFiles` +- `Grep` +- `FileGlob` +- `FileGlobV2` + +### Why + +These tools primarily need session, cwd, shell launch data, and permission context. They do not require GUI terminal blocks or TUI UI state. + +Sharing them proves the executor is not just shared routing; it also provides real shared execution. + +### Alternatives considered + +- **Keep the old GUI `ReadFilesExecutor`, `GrepExecutor`, and `FileGlobExecutor` models and add TUI equivalents.** + Rejected because it would keep duplicate execution paths. +- **Keep the old executor models as GUI fallbacks.** + Rejected after the shared defaults compiled and worked structurally. The old models became dead weight and confused ownership. + +## 4. Keep shell command execution surface-specific for v0 + +### Decision + +Shell command tools route through `SurfaceSpecificToolExecutor`. + +- GUI delegates to `ShellCommandExecutor` and terminal blocks. +- TUI uses a local `Session`-backed command execution path. + +### Why + +GUI command execution is terminal-block-backed: it emits terminal events, observes `TerminalModel` block output, supports long-running block reads/writes, and integrates with GUI control handoff. + +TUI does not have terminal blocks and should not fake them. + +### Alternatives considered + +- **Reuse `ShellCommandExecutor` directly in TUI.** + Rejected because `ShellCommandExecutor` depends on `TerminalModel` and GUI terminal events. +- **Create fake terminal blocks for TUI.** + Rejected as misleading and likely to create brittle coupling. +- **Fully generalize shell execution now.** + Considered desirable long-term, but too large for v0 because long-running process state, read/write follow-up, cancellation, and user control handoff need a real TUI command model. + +## 5. Treat long-running shell follow-up tools as not implemented in TUI v0 + +### Decision + +TUI v0 does not fully implement: + +- `ReadShellCommandOutput` +- `WriteToLongRunningShellCommand` +- `TransferShellCommandControlToUser` + +These return `BlockNotFound`-style results until TUI has its own persistent command/process registry. + +### Why + +These actions refer to command/block identity and ongoing process state. GUI uses `BlockId` and `TerminalModel` for that. + +TUI needs a `TuiCommandModel` or equivalent before these tools can be correct. + +### Alternatives considered + +- **Store minimal command ids in the first PR.** + Considered but deferred because a correct implementation needs snapshots, stdin writes, cancellation, and control handoff semantics. +- **Map TUI command ids directly onto `BlockId` without a backing model.** + Rejected because it would only satisfy the type shape, not the behavior. + +## 6. Keep file edit execution surface-specific, but share diff application + +### Decision + +`RequestFileEdits` routes through `SurfaceSpecificToolExecutor`. + +- GUI keeps `RequestFileEditsExecutor` and `CodeDiffView`. +- TUI uses shared diff application and auto-saves for v0. + +### Why + +Diff parsing and matching are shared logic. Approval, saving UI, and result timing are surface-specific. + +GUI needs `CodeDiffView` and accept/reject behavior. TUI v0 intentionally auto-accepts and shows a minimal card. + +### Alternatives considered + +- **Reuse `CodeDiffView` from TUI.** + Rejected because TUI should not depend on GUI editor/view types. +- **Build rich TUI accept/reject UI in this PR.** + Rejected for v0 to keep the first tool-calling path focused on execution correctness. +- **Make file edits fully shared immediately.** + Rejected because GUI and TUI have different approval/save semantics. + +## 7. Extract shared queue/result state into `AgentToolActionModel` + +### Decision + +`AgentToolActionModel` owns shared action state: + +- preprocessing queues; +- pending actions; +- running actions; +- finished results; +- action ordering; +- past results. + +GUI wraps it through `BlocklistAIActionModel`; TUI uses it through `TuiToolActionModel`. + +### Why + +The model named `BlocklistAIActionModel` includes GUI/blocklist-specific behavior and naming. + +TUI should not depend directly on a model whose name and surrounding responsibilities imply GUI Agent Mode internals. + +### Alternatives considered + +- **Rename `BlocklistAIActionModel` wholesale.** + Considered, but the model still contains GUI-specific status updates and shared-session/view details, so a full rename would overstate how generic it is. +- **Let TUI use `BlocklistAIActionModel` directly.** + Rejected because it would couple TUI to GUI/blocklist concepts. +- **Keep TUI's own independent action state.** + Rejected because action ordering/result draining semantics should be common. + +## 8. Encapsulate running action state in `AgentToolActionModel` + +### Decision + +`AgentToolActionModel` exposes methods for: + +- recording running actions; +- finishing running actions; +- checking whether a conversation still has running actions. + +TUI no longer maintains a separate `running_action_counts` map. + +### Why + +The duplicate TUI counter introduced a second source of truth and could fire `ActionsFinished` too early for synchronous multi-tool batches. + +The shared model already owns running action state and should expose the operations needed by both surfaces. + +### Alternatives considered + +- **Keep `running_action_counts` in TUI.** + Rejected because it could diverge from shared action state. +- **Expose the `running_actions` map broadly.** + Rejected because callers should not need to understand or mutate the internal representation. + +## 9. Record action order separately from action execution + +### Decision + +The method formerly named `start_action_batch` was renamed to `record_action_order`. + +### Why + +The method only records original tool-call order so finished results can be drained in a deterministic order. It does not start execution or mark actions running. + +### Alternatives considered + +- **Keep the old name.** + Rejected because it made the shared model harder to understand. + +## 10. Keep TUI tool UI minimal and TUI-specific + +### Decision + +TUI renders simple tool cards with concise summaries. + +The TUI-only card type is named `TuiToolCard` and remains in `app/src/tui/tool_model.rs`. + +### Why + +The card is a rendering affordance, not shared tool execution state. + +Keeping it TUI-specific prevents shared execution code from accumulating UI concerns. + +### Alternatives considered + +- **Share `AgentToolCard` as a common model.** + Rejected because GUI does not use the same card shape and richer UI will likely diverge. +- **Let card fallback print full action result strings.** + Rejected for shared tool results because read/grep/glob outputs can be too verbose for v0 cards. + +## 11. Do not advertise unsupported TUI tools as complete behavior + +### Decision + +The architecture now allows all tool calls to enter `AgentToolExecutor`, but only a subset has meaningful TUI implementations. + +Unsupported tools return cancelled-style results through the default surface-specific fallback. + +### Why + +The goal of this refactor was to centralize execution and get key tools working, not to claim full TUI parity. + +The current meaningful TUI tool coverage is: + +- command execution; +- file edits; +- read files; +- grep; +- file glob. + +### Alternatives considered + +- **Claim TUI supports all tools because all actions route through `AgentToolExecutor`.** + Rejected as inaccurate: shared routing is not the same as meaningful execution. +- **Restrict `supported_tools_override` to the implemented subset immediately.** + This remains a valid next step if we want the server/model to avoid requesting unsupported tools. +- **Move all GUI-only tools into shared defaults in this PR.** + Rejected as too broad; each remaining tool needs a specific review of whether it is truly surface-neutral. + +## 12. Keep tests and live TUI validation separate from compile-only cleanup when requested + +### Decision + +During the later cleanup pass, only formatting and `cargo check` were run. + +Tests and live TUI/app execution were skipped when requested. + +### Why + +Some test/app commands require local password input and the user was away from the machine. + +Compile-only validation still caught integration errors in the shared executor refactor without requiring interactive access. + +### Alternatives considered + +- **Run TUI live validation immediately.** + Rejected for that pass because the user explicitly asked not to run code/tests. +- **Skip validation entirely.** + Rejected because `cargo check` is non-interactive and necessary for a refactor crossing shared GUI/TUI code. + +## 13. Extract the scheduling loop into a shared `AgentToolScheduler` + +### Decision + +The scheduling loop — preprocessing fan-out, the pending queue, serial/parallel phase admission, ordered result draining, and follow-up readiness — moved out of `BlocklistAIActionModel` into a shared `AgentToolScheduler` whose static methods are generic over an `AgentToolScheduleHost` trait. + +`AgentToolActionModel` remains pure embedded state. `BlocklistAIActionModel` (GUI) and `TuiToolActionModel` (TUI) are thin host adapters: they implement `AgentToolScheduleHost` (execution primitives plus side-effect hooks) and delegate the loop to `AgentToolScheduler`. + +### Why + +Decisions 1–3 shared execution dispatch and decision 7 shared action state, but the scheduling loop itself was still GUI-only. The TUI reimplemented a degenerate version that marked every action running and spawned them all at once, with no serial barrier. Sharing the loop removes that divergence: the TUI now inherits identical ordering and phase semantics, gaining serial barriers for mutating tools and parallel fan-out for read-only tools. + +### Alternatives considered + +- **A single model parameterized by surface (`AgentToolActionModel`).** + Rejected because view-only/session-sharing behavior and the GUI's sub-executor accessors are GUI-only; folding them into one shared type would accrete surface flags and conditionals. +- **Share only decision helpers and keep a per-surface drive loop.** + Rejected because the bug-prone drain/admission loop would be duplicated and could drift — the same class of divergence decision 8 already fixed for running-action state. + +## 14. Preserve each surface's async-completion mechanism behind `try_execute` + +### Decision + +`AgentToolScheduler` never spawns execution futures. Each host's `try_execute` returns a synchronous `TryExecuteResult` and guarantees that `AgentToolScheduler::finish_action` is eventually called: the GUI completes via its executor's `FinishedAction` event subscription, the TUI via its own `ctx.spawn` callback. The TUI delivers even `Sync`/`NotReady`/`InvalidAction` results through a deferred ready-future spawn to avoid re-entrant scheduling. + +### Why + +GUI completion is event-decoupled (the executor model emits, the action model subscribes); TUI completion is inline on the owning model. Forcing one mechanism would mean rewriting the GUI executor's `async_executing_actions` tracking and cancellation, which is out of scope and risky. + +### Alternatives considered + +- **Move spawning into the scheduler, generic over the host context.** + Rejected as more invasive and incompatible with the existing executor-owned async tracking/cancellation. + +## 15. Test shared scheduling deterministically with a mock host + +### Decision + +Phase-admission behavior is tested in `app/src/ai/blocklist/action_model/scheduler_tests.rs` with a mock `AgentToolScheduleHost` whose `try_execute` records actions as running but never completes them, and whose `spawn_after_preprocess` runs its callback synchronously. Tests assert post-queue running/pending counts (two reads → both running; file-edit then command → one running, one pending). + +### Why + +Observing the transient running/pending state of real async execution is racy: the test executor can drain the spawned execution futures before the assertion, so the counts are not stable. A non-completing mock host makes admission state deterministic without timing dependence. + +### Alternatives considered + +- **Drive the real TUI executor and assert counts after yields.** + Rejected as flaky; the first attempt at this relied on `yield` timing and could not reliably catch the transient "both running" window. diff --git a/specs/tui-toolcalling/TECH.md b/specs/tui-toolcalling/TECH.md new file mode 100644 index 0000000000..a02a7a3cd4 --- /dev/null +++ b/specs/tui-toolcalling/TECH.md @@ -0,0 +1,144 @@ +# TUI tool execution for Agent Mode +## Context +The `warp-tui` prototype can submit prompts and fold streamed text into shared conversation history, but it disables client tools by setting `supported_tools_override: Some(vec![])` in [`app/src/tui.rs:758 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/tui.rs#L758). This spec enables v0 TUI tool execution with automatic tool acceptance and minimal status cards. +The GUI Agent Mode tool path already has the right high-level shape: +* Streamed server `ClientActions` become `AIAgentAction`s and are folded into history by `AgentConversationEngine` ([`app/src/ai/blocklist/agent_conversation_engine.rs:38 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/agent_conversation_engine.rs#L38)). +* GUI queues completed-stream actions through the engine delegate ([`app/src/ai/blocklist/controller.rs:2744 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/controller.rs#L2744)). +* `BlocklistAIActionModel` owns action queueing, preprocessing, running/finished state, and ordered finished results ([`app/src/ai/blocklist/action_model.rs:247 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/action_model.rs#L247), [`app/src/ai/blocklist/action_model.rs:914 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/action_model.rs#L914)). +* `BlocklistAIController` drains finished results into `RequestInput::for_actions_results` and sends the tool-result follow-up ([`app/src/ai/blocklist/controller.rs:213 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/controller.rs#L213), [`app/src/ai/blocklist/controller.rs:1522 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/controller.rs#L1522)). +The reusable session layer already exists. `Session` owns shell metadata, launch data, session type, path conversion, and `CommandExecutor` access ([`app/src/terminal/model/session.rs:901 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/terminal/model/session.rs#L901), [`app/src/terminal/model/session.rs:951 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/terminal/model/session.rs#L951), [`app/src/terminal/model/session.rs:1472 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/terminal/model/session.rs#L1472)). `ActiveSession` is a GUI terminal-pane model that points at the currently active bootstrapped `Session` and tracks cwd from terminal metadata ([`app/src/terminal/model/session/active_session.rs:16 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/terminal/model/session/active_session.rs#L16), [`app/src/terminal/model/session/active_session.rs:69 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/terminal/model/session/active_session.rs#L69)). +The main coupling to remove is not `Session`; it is the direct dependency from reusable tools onto GUI terminal/editor concepts: +* Many read-only tools only need cwd/shell/session data, but currently store `ModelHandle` ([`read_files.rs:15 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/action_model/execute/read_files.rs#L15), [`grep.rs:178 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/action_model/execute/grep.rs#L178), [`file_glob.rs:31 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/action_model/execute/file_glob.rs#L31)). +* `ShellCommandExecutor` is terminal-block-backed: it emits terminal events and waits for `TerminalModel` block output ([`shell_command.rs:38 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/action_model/execute/shell_command.rs#L38), [`shell_command.rs:235 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/action_model/execute/shell_command.rs#L235), [`shell_command.rs:487 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/action_model/execute/shell_command.rs#L487)). +* `RequestFileEditsExecutor` preprocesses candidate diffs but execution waits on a registered GUI `CodeDiffView` to save/reject ([`request_file_edits.rs:45 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/action_model/execute/request_file_edits.rs#L45), [`request_file_edits.rs:120 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/action_model/execute/request_file_edits.rs#L120), [`code_diff_view.rs:1022 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/inline_action/code_diff_view.rs#L1022)). +## Proposed changes +### TUI local session +The TUI owns a single local `Session` built by `tui_local_session()` from the process cwd/shell environment and backed by `LocalCommandExecutor`. It is held directly on `TuiToolActionModel` as `session: Arc`; there is no separate `TuiActiveSession` model. This can later grow to multiple concurrent sessions/processes. +### Execution context for shared tools +Surface-neutral tools never store `ModelHandle`. Each surface instead produces an `AgentToolExecutionContext` on demand through `SurfaceSpecificToolExecutor::tool_execution_context`. +```rust +pub(crate) struct AgentToolExecutionContext { + current_working_directory: Option, + shell_launch_data: Option, + session: Option>, + terminal_view_id: Option, +} +``` +GUI builds it from `ActiveSession`; TUI builds it from its local `Session` (with no `terminal_view_id`). Shared file-edit diff application takes a `SessionContext` derived the same way. +### Shared action state and scheduling +`AgentToolActionModel` is plain embedded state (not an `Entity`): preprocessing queues, pending actions, running actions, finished results, action ordering, and past results. Both surfaces hold it as a `tools` field rather than wrapping a `ModelHandle`. +```rust +pub(crate) struct AgentToolActionModel { + // shared queue/result state +} +``` +The scheduling loop — preprocessing fan-out, the pending queue, serial/parallel phase admission, ordered result draining, and follow-up readiness — lives in a shared `AgentToolScheduler` parameterized over an `AgentToolScheduleHost` trait. The host is implemented by the model that owns the state (`BlocklistAIActionModel` for GUI, `TuiToolActionModel` for TUI), so scheduler callbacks (`ctx.spawn`, event emission, status updates) land on the owning model. +```rust +pub(crate) trait AgentToolScheduleHost: Sized { + type Context<'a>; + fn tools(&mut self) -> &mut AgentToolActionModel; + // execution: preprocess / try_execute / can_autoexecute / action_phase + // side effects: on_action_enqueued / on_action_started / on_action_not_executed + // / on_action_finished / on_phase_drained / should_enqueue +} + +pub(crate) struct AgentToolScheduler; // generic static methods over the host +``` +`AgentToolScheduler` owns `queue_actions`, `try_to_execute_available_actions`, `start_pending_action_by_id`, and `finish_action`. Surface-specific execution (shell, file edits) and side effects (history status, event emission, TUI cards) are delegated to the host. GUI-specific inline views, shared-session viewer UI, terminal block events, `TerminalModel`, `AIBlock`, `RequestedCommandView`, and `CodeDiffView` stay in the GUI adapter. +Generic state queries also live on `AgentToolActionModel`, not on the surface wrappers. `get_pending_actions`, `get_pending_actions_for_conversation`, `get_pending_action_by_id`, `has_unfinished_actions_for_conversation`, `get_finished_action_results`, `get_action_result`, `restore_action_results_from_exchanges`, `blocked_action_for_conversation`, and `get_action_status` are implemented on the shared model. `get_action_status` takes `is_view_only: bool` as a parameter so the GUI can pass its view-only flag and the TUI can pass `false`; the `Blocked` vs `Queued` distinction is the only status rule that depends on surface state. The surface wrappers keep thin delegating methods so existing GUI view code (`block.rs`, `output.rs`, etc.) continues to call `action_model.get_action_status(id)` unchanged. Running-action recording is consolidated into `AgentToolActionModel::record_running_action` (with the phase-consistency `debug_assert`), so the GUI no longer carries a duplicate `add_running_action`. +Queries that genuinely depend on surface state stay on the wrappers: `get_pending_action(app)`, `get_pending_or_running_action_id(app)`, `has_unfinished_actions(app)`, and `get_async_running_action(app)` all resolve the active conversation through GUI-specific `terminal_view_id` + `BlocklistAIHistoryModel`, and `get_async_running_action` additionally consults the GUI executor's in-flight action map. `mark_action_as_remotely_executing` stays GUI-specific because it is gated on `is_view_only` and emits a GUI event. +The two surfaces keep different async-completion mechanisms behind `try_execute`: GUI execution completes via its executor's `FinishedAction` event subscription, TUI execution via its own `ctx.spawn` callback; both call back into `AgentToolScheduler::finish_action`. For v0, TUI auto-accepts (no user-confirmation blocked state) and inherits the shared phased loop, so read-only tools fan out in one parallel phase while shell and file-edit tools run as serial barriers. +### Shared-first tool executor +Tool execution is centralized in a shared `AgentToolExecutor` used by both GUI and TUI. Shared tools are handled directly; inherently surface-specific tools are delegated to a required `SurfaceSpecificToolExecutor` implemented by each surface. `AgentToolExecutor` is a unit type with static methods generic over the surface — it holds no state. +```rust +pub(crate) struct AgentToolExecutor; // static methods generic over the surface + +pub(crate) trait SurfaceSpecificToolExecutor { + type Context<'a>; + + fn tool_execution_context(&self, ctx: &Self::Context<'_>) -> AgentToolExecutionContext; + fn tool_execution_context_from_app(&self, ctx: &AppContext) -> AgentToolExecutionContext; + fn app_context<'a, 'b>(ctx: &'a Self::Context<'b>) -> &'a AppContext; + + // Required, surface-specific tool families. + fn preprocess_shell(&mut self, input: PreprocessActionInput<'_>, ctx: &mut Self::Context<'_>) -> BoxFuture<'static, ()>; + fn execute_shell(&mut self, input: ExecuteActionInput<'_>, ctx: &mut Self::Context<'_>) -> AnyActionExecution; + fn should_autoexecute_shell(&mut self, input: ExecuteActionInput<'_>, ctx: &mut Self::Context<'_>) -> bool; + fn preprocess_file_edits(&mut self, input: PreprocessActionInput<'_>, ctx: &mut Self::Context<'_>) -> BoxFuture<'static, ()>; + fn execute_file_edits(&mut self, input: ExecuteActionInput<'_>, ctx: &mut Self::Context<'_>) -> AnyActionExecution; + fn should_autoexecute_file_edits(&mut self, input: ExecuteActionInput<'_>, ctx: &mut Self::Context<'_>) -> bool; + + // Defaulted fallbacks for GUI-only tools (no-op / cancelled / serial). + fn preprocess_other(&mut self, input: PreprocessActionInput<'_>, ctx: &mut Self::Context<'_>) -> BoxFuture<'static, ()> { /* no-op */ } + fn execute_other(&mut self, input: ExecuteActionInput<'_>, ctx: &mut Self::Context<'_>) -> AnyActionExecution { /* cancelled */ } + fn should_autoexecute_other(&mut self, input: ExecuteActionInput<'_>, ctx: &mut Self::Context<'_>) -> bool { false } + fn action_phase_other(&self, action: &AIAgentAction, ctx: &AppContext) -> RunningActionPhase { /* Serial */ } +} +``` +`AgentToolExecutor`'s static `preprocess_action` / `execute_action` / `should_autoexecute` / `action_phase` own the only top-level `AIAgentActionType` dispatch. Read files, grep, and file glob run shared default logic (using the surface's `AgentToolExecutionContext`); shell commands and file edits delegate to the required `SurfaceSpecificToolExecutor` methods; every remaining tool falls through to the defaulted `*_other` hooks. +The GUI `BlocklistAIActionExecutor` implements the trait by delegating to existing GUI machinery: +* shell commands use `ShellCommandExecutor`, `TerminalModel`, and terminal blocks; +* file edits use `RequestFileEditsExecutor` and `CodeDiffView`; +* GUI-only tools (MCP, computer use, start/run agents, documents, etc.) are handled in `preprocess_other`/`execute_other`. +The TUI `TuiToolExecutor` supplies only TUI-specific behavior: +* shell commands run on the TUI local `Session`; +* file edits use shared diff application plus v0 auto-save; +* unsupported tools fall through to the cancelled `*_other` defaults. +Both surfaces route through the same `AgentToolExecutor`; there is no separate TUI-only dispatch tree. +### Tool-result follow-up +The tool-result turn closes the loop: +```text +stream response -> execute tools -> send tool results -> continue streaming +``` +This stayed surface-specific rather than moving into `AgentConversationEngine`. The GUI keeps `BlocklistAIController`'s follow-up path. The TUI drives it from `CoreTuiModel::send_action_results`, triggered by `TuiToolActionEvent::ActionsFinished` — which the scheduler raises from its `on_phase_drained` hook once a conversation has no pending or running actions. `send_action_results` drains finished results in original tool-call order, builds `RequestInput` from the local session, and sends the follow-up through `AgentConversationEngine`. +### TUI tool pipeline +`CoreTuiModel` owns or references: +* the single `AgentSessionOwnerId`; +* `TuiToolActionModel`, which owns the local `Session` and the shared `AgentToolActionModel` state; +* the follow-up hookup: it subscribes to `TuiToolActionModel` and calls `send_action_results` on `ActionsFinished`. +`supported_tools_override: Some(vec![])` has been removed from normal TUI prompt sends. +### Command running +Command execution is surface-specific, required by `SurfaceSpecificToolExecutor`. The GUI keeps terminal-block behavior through `ShellCommandExecutor`. +For v0 the TUI runs each `RequestCommandOutput` synchronously on its local `Session`, captures combined stdout/stderr, and returns `Completed` with a freshly generated `BlockId` at the result boundary (existing result types still key commands by `BlockId` — [`crates/ai/src/agent/action_result/mod.rs:183 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/crates/ai/src/agent/action_result/mod.rs#L183)). It has no persistent command registry yet, so `ReadShellCommandOutput`, `WriteToLongRunningShellCommand`, and `TransferShellCommandControlToUser` return `BlockNotFound`. A `TuiCommandModel` with snapshots, stdin, and cancellation is a follow-up. +### File edits +Keep `diff_application::apply_edits` as the shared diff parser/matcher; it already accepts `SessionContext` and a file-read closure ([`diff_application.rs:161 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/action_model/execute/request_file_edits/diff_application.rs#L161)). +Change `ApplyDiffModel::apply_diffs` to take the session snapshot/session context as input instead of storing `ModelHandle` ([`apply_diff_model.rs:25 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/action_model/execute/request_file_edits/apply_diff_model.rs#L25)). +File edit execution is surface-specific and should be required by `SurfaceSpecificToolExecutor`, but diff application should be shared. GUI can keep `CodeDiffView` for approval and saving. TUI uses the same diff application logic, then auto-saves for v0. +For v0, TUI auto-accepts file edits after preprocessing succeeds. It writes local files, computes unified diff/line stats/deleted files, and returns `RequestFileEditsResult::Success`. Diff application failures return `RequestFileEditsResult::DiffApplicationFailed` so the agent can recover ([`request_file_edits.rs:135 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/app/src/ai/blocklist/action_model/execute/request_file_edits.rs#L135), [`convert.rs:197 @ f2592f0`](https://github.com/warpdotdev/warp/blob/f2592f04a9c6544780d830058d6571a2f091df80/crates/ai/src/agent/action_result/convert.rs#L197)). +### Minimal TUI tool UI +Add simple TUI tool cards next to the current transcript/input views. No rich approval/edit UI in this PR. +Example command card: +```text +┌ Tool: run_shell_command ──────────────────────┐ +│ cargo check -p warp --features tui │ +│ exit 0 · 213 lines captured │ +└───────────────────────────────────────────────┘ +``` +Example file-edit card: +```text +┌ Tool: apply_file_diffs ───────────────────────┐ +│ app/src/tui.rs │ +│ +24 -8 · applied automatically │ +└───────────────────────────────────────────────┘ +``` +## Testing and validation +Automated tests: +* TUI model test: tool call is queued, auto-executed, result is sent as a follow-up, final conversation stays ordered. +* Command backend tests: completed command, denylisted command, long-running snapshot, read-after-snapshot, write-to-running-command, cancellation, pager decoration. +* File-edit tests: candidate diff generation, auto-accept/save success, malformed/unmatched diff failure, protected-path denial, returned updated file context. +* TUI presenter tests: minimal command/file-edit/tool summary cards. +Targeted commands: +```sh +cargo test -p warp --features tui +cargo test -p warp --features tui +cargo test -p warp --features tui +cargo check -p warp --features tui +``` +Manual validation is required. Build and run `warp-tui`, submit prompts inside the TUI that trigger command and file-edit tools, observe minimal tool cards and follow-up behavior, fix issues, and repeat until the interactive behavior works as intended. This is required because the feature is an interactive terminal UI, not only a model path. +## Parallelization +Do not use parallel implementation agents for the first pass. The core refactor crosses shared action state, session plumbing, shell execution, file edits, and the TUI transcript, and those pieces need to be changed in a tight sequence to keep the code compiling. Parallelization becomes useful after the shared backing model and session snapshot boundaries land; at that point UI polish and additional tool executor conversions can be split off safely. +## Follow-ups +* Rich TUI approval/edit UI for commands and file edits. +* Better TUI command interleaving for user-authored shell commands. +* Generalize `ActiveSession` if the GUI `ActiveSession` and the TUI local `Session` converge. +* Remove compatibility wrappers once `BlocklistAIActionModel` is thin enough to rename or delete.