pub(super) mod ask_user_question; pub(super) mod call_mcp_tool; pub(super) mod create_documents; pub(super) mod edit_documents; 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; pub(super) mod request_file_edits; pub(super) mod search_codebase; pub(super) mod send_message; pub(super) mod shell_command; pub(super) mod start_agent; pub(super) mod suggest_new_conversation; pub(super) mod suggest_prompt; pub(super) mod upload_artifact; pub(super) mod use_computer; use ai::agent::action_result::{InsertReviewCommentsResult, RequestCommandOutputResult}; pub use ask_user_question::AskUserQuestionExecutor; pub(crate) use call_mcp_tool::coerce_integer_args; 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, FutureExt}; use galaxy_core::{execution_mode::AppExecutionMode, features::FeatureFlag}; use grep::GrepExecutor; 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; pub(crate) use request_file_edits::apply_edits; pub(crate) use request_file_edits::FileReadResult; pub(crate) use request_file_edits::MalformedFinalLineProxyEvent; pub use request_file_edits::{ EditAcceptAndContinueClickedEvent, EditAcceptClickedEvent, EditResolvedEvent, EditStats, RequestFileEditsExecutor, RequestFileEditsFormatKind, RequestFileEditsTelemetryEvent, }; pub use send_message::SendMessageToAgentExecutor; use serde::{Deserialize, Serialize}; pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent}; pub use start_agent::{StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest}; pub use suggest_new_conversation::NewConversationDecision; use suggest_new_conversation::SuggestNewConversationExecutor; pub use suggest_prompt::PromptSuggestionExecutor; use upload_artifact::UploadArtifactExecutor; use use_computer::UseComputerExecutor; #[cfg(feature = "local_fs")] use crate::util::openable_file_type::is_binary_file; #[cfg(feature = "local_fs")] use futures::AsyncReadExt; #[cfg(feature = "local_fs")] use galaxy_files::{FileModel, TextFileReadResult}; #[cfg(feature = "local_fs")] use galaxy_util::file::FileLoadError; #[cfg(feature = "local_fs")] use galaxy_util::file_type::is_buffer_binary; use galaxyui::{ r#async::{Spawnable, SpawnableOutput}, AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity, }; use std::{any::Any, path::PathBuf, pin::Pin, sync::Arc}; #[cfg(feature = "local_fs")] use crate::util::image::{ is_supported_image_mime_type, process_image_for_agent, ProcessImageResult, }; #[cfg(feature = "local_fs")] use mime_guess::from_path; use self::search_codebase::SearchCodebaseExecutor; #[cfg(feature = "local_fs")] use crate::ai::{agent::AnyFileContent, paths::host_native_absolute_path}; use crate::{ ai::{ agent::{ conversation::AIConversationId, AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, AIAgentActionType, CancellationReason, FileContext, FileLocations, ServerOutputId, }, ambient_agents::AmbientAgentTaskId, get_relevant_files::controller::GetRelevantFilesController, }, terminal::{ model::session::{active_session::ActiveSession, ExecuteCommandOptions, Session}, model_events::ModelEventDispatcher, shell::ShellType, ShellLaunchData, TerminalModel, }, BlocklistAIHistoryModel, }; /// Types of actions that can be executed in parallel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) 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, } /// Whether an action is running serially or in parallel with other actions. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) 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. Parallel(ParallelExecutionPolicy), } #[derive(Debug, Clone, Copy)] struct ExecuteActionInput<'a> { action: &'a AIAgentAction, conversation_id: AIConversationId, } #[derive(Debug, Clone, Copy)] struct PreprocessActionInput<'a> { action: &'a AIAgentAction, conversation_id: AIConversationId, } type AsyncExecuteActionFn = Pin>>; type OnCompleteFn = Box AIAgentActionResultType>; enum ActionExecution { Async { execute_future: AsyncExecuteActionFn, on_complete: OnCompleteFn, }, Sync(AIAgentActionResultType), NotReady, InvalidAction, } impl ActionExecution { fn new_async( execute_future: impl Spawnable, on_complete: impl FnOnce(T, &mut AppContext) -> AIAgentActionResultType + 'static, ) -> Self { Self::Async { execute_future: Box::pin(execute_future), on_complete: Box::new(on_complete), } } } /// A trait implemented by all types that implement [`Any`] and [`SpawnableOutput`]. trait AnySpawnableOutput: Any + SpawnableOutput {} impl AnySpawnableOutput for T where T: Any + SpawnableOutput {} type AnyAsyncExecuteActionFn = Pin>>>; type AnyOnCompleteFn = Box, &mut AppContext) -> AIAgentActionResultType>; enum AnyActionExecution { Async { execute_future: AnyAsyncExecuteActionFn, on_complete: AnyOnCompleteFn, }, Sync(AIAgentActionResultType), NotReady, InvalidAction, } impl From> for AnyActionExecution where T: Send + 'static, { fn from(value: ActionExecution) -> Self { match value { ActionExecution::Async { execute_future, on_complete, } => AnyActionExecution::Async { execute_future: Box::pin(async move { let result = execute_future.await; Box::new(result) as Box }), on_complete: Box::new(move |result, app| { on_complete(*result.downcast::().expect("Type is correct."), app) }), }, ActionExecution::Sync(result) => AnyActionExecution::Sync(result), ActionExecution::NotReady => AnyActionExecution::NotReady, ActionExecution::InvalidAction => AnyActionExecution::InvalidAction, } } } #[derive(Debug, Copy, Clone)] pub enum NotExecutedReason { NotReady, NeedsConfirmation, WaitingOnSharer, } impl NotExecutedReason { pub fn needs_confirmation(&self) -> bool { matches!(self, Self::NeedsConfirmation) } } /// Result type for `BlocklistAIActionExecutor::try_to_execute_action`. #[derive(Debug)] pub(super) enum TryExecuteResult { ExecutedSync, ExecutedAsync, NotExecuted { reason: NotExecutedReason, action: Box, }, } #[derive(Clone)] struct AsyncExecutingAction { action: AIAgentAction, /// The conversation this action belongs to so cancellation and follow-up scheduling remain /// scoped even when several conversations have async actions in flight. conversation_id: AIConversationId, } impl AsyncExecutingAction { fn is_shell_command_action(&self) -> bool { matches!( self.action.action, AIAgentActionType::RequestCommandOutput { .. } | AIAgentActionType::WriteToLongRunningShellCommand { .. } | AIAgentActionType::ReadShellCommandOutput { .. } ) } } pub struct BlocklistAIActionExecutor { 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, suggest_prompt_executor: ModelHandle, read_documents_executor: ModelHandle, edit_documents_executor: ModelHandle, create_documents_executor: ModelHandle, use_computer_executor: ModelHandle, request_computer_use_executor: ModelHandle, read_skill_executor: ModelHandle, fetch_conversation_executor: ModelHandle, start_agent_executor: ModelHandle, send_message_executor: ModelHandle, ask_user_question_executor: ModelHandle, /// The actions currently executing asynchronously, keyed by action ID. /// We track them per action rather than as a single slot so multiple actions from the same /// parallel phase can complete independently. async_executing_actions: std::collections::HashMap, /// Reference to the terminal model for checking session sharing state. terminal_model: Arc>, } impl BlocklistAIActionExecutor { pub fn new( terminal_model: Arc>, active_session: ModelHandle, model_event_dispatcher: &ModelHandle, get_relevant_files_controller: ModelHandle, 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| { SearchCodebaseExecutor::new( active_session.clone(), get_relevant_files_controller, terminal_view_id, ctx, ) }); let shell_command_executor = ctx.add_model(|ctx| { ShellCommandExecutor::new( active_session.clone(), terminal_model.clone(), model_event_dispatcher, terminal_view_id, ctx, ) }); 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 = ctx.add_model(|_| CallMCPToolExecutor::new(active_session.clone(), terminal_view_id)); let suggest_new_conversation_executor = ctx.add_model(|_| SuggestNewConversationExecutor::new()); let suggest_prompt_executor = ctx.add_model(|_| PromptSuggestionExecutor::new()); let read_documents_executor = ctx.add_model(|_| ReadDocumentsExecutor::new()); let edit_documents_executor = ctx.add_model(|_| EditDocumentsExecutor::new()); let create_documents_executor = ctx .add_model(|_| CreateDocumentsExecutor::new(active_session.clone(), terminal_view_id)); let use_computer_executor = ctx.add_model(|_| UseComputerExecutor::new()); let request_computer_use_executor = ctx.add_model(|_| RequestComputerUseExecutor::new(terminal_view_id)); let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new()); let fetch_conversation_executor = ctx.add_model(|_| FetchConversationExecutor::new()); let start_agent_executor = ctx.add_model(StartAgentExecutor::new); let send_message_executor = ctx.add_model(|_| SendMessageToAgentExecutor::new()); let ask_user_question_executor = ctx.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id)); 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, suggest_prompt_executor, read_documents_executor, edit_documents_executor, create_documents_executor, use_computer_executor, request_computer_use_executor, async_executing_actions: Default::default(), terminal_model, read_skill_executor, fetch_conversation_executor, start_agent_executor, send_message_executor, ask_user_question_executor, } } pub fn async_executing_action(&self, action_id: &AIAgentActionId) -> Option<&AIAgentAction> { self.async_executing_actions .get(action_id) .map(|running| &running.action) } pub fn shell_command_executor(&self) -> &ModelHandle { &self.shell_command_executor } pub fn request_file_edits_executor(&self) -> &ModelHandle { &self.request_file_edits_executor } pub fn search_codebase_executor(&self) -> &ModelHandle { &self.search_codebase_executor } pub fn suggest_new_conversation_executor( &self, ) -> &ModelHandle { &self.suggest_new_conversation_executor } pub fn suggest_prompt_executor(&self) -> &ModelHandle { &self.suggest_prompt_executor } pub fn start_agent_executor(&self) -> &ModelHandle { &self.start_agent_executor } 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, } } pub fn ask_user_question_executor(&self) -> &ModelHandle { &self.ask_user_question_executor } pub fn set_ambient_agent_task_id( &self, id: Option, ctx: &mut ModelContext, ) { self.request_computer_use_executor .update(ctx, |executor, _| { executor.set_ambient_agent_task_id(id); }); } pub fn preprocess_action( &self, action: &AIAgentAction, conversation_id: AIConversationId, ctx: &mut ModelContext, ) -> BoxFuture<'static, ()> { // In view-only mode, we do not need to perform any preprocessing work. if self.is_shared_session_viewer() { return futures::future::ready(()).boxed(); } let input = PreprocessActionInput { action, 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)), } } /// Returns `None` if the action was executed (and thereby consumed). /// /// If the executor cannot execute the action at this time, returns a result indicating why. pub fn try_to_execute_action( &mut self, action: AIAgentAction, conversation_id: AIConversationId, is_user_initiated: bool, ctx: &mut ModelContext, ) -> TryExecuteResult { // We should never actually execute actions in view-only mode. if self.is_shared_session_viewer() { return TryExecuteResult::NotExecuted { reason: NotExecutedReason::WaitingOnSharer, action: Box::new(action), }; } let input = ExecuteActionInput { action: &action, conversation_id, }; let can_auto_execute = self.should_autoexecute(input, ctx); let is_agent_autonomous = AppExecutionMode::as_ref(ctx).is_autonomous(); // The agent cannot auto execute and either: // - the agent is interactive, OR // - the agent is autonomous and the action was not requesting command output let needs_confirmation = !(is_user_initiated || can_auto_execute || (is_agent_autonomous && action.action.is_request_command_output())); if needs_confirmation { return TryExecuteResult::NotExecuted { action: Box::new(action), reason: NotExecutedReason::NeedsConfirmation, }; } else if !is_user_initiated && !can_auto_execute && is_agent_autonomous { // It must be the case that the autonomous agent is requesting a denylisted command. if let AIAgentActionType::RequestCommandOutput { command, .. } = &action.action { let action_id = action.id.clone(); let result = AIAgentActionResultType::RequestCommandOutput( RequestCommandOutputResult::Denylisted { command: command.clone(), }, ); ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction { action_id: action_id.clone(), }); ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction { result: Arc::new(AIAgentActionResult { id: action_id, task_id: action.task_id.clone(), result, }), conversation_id, cancellation_reason: None, }); return TryExecuteResult::ExecutedSync; } } 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(), }; 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 { execute_future, on_complete, } => { self.async_executing_actions.insert( action_id.clone(), AsyncExecutingAction { action: action_clone, conversation_id, }, ); ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction { action_id: action_id.clone(), }); ctx.spawn(execute_future, move |me, result, ctx| { let Some(running) = me.async_executing_actions.remove(&action_id) else { return; }; let result = on_complete(result, ctx); ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction { result: Arc::new(AIAgentActionResult { id: action_id, task_id: running.action.task_id, result, }), conversation_id: running.conversation_id, cancellation_reason: None, }); }); TryExecuteResult::ExecutedAsync } AnyActionExecution::Sync(action_result) => { ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction { action_id: action_id.clone(), }); ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction { result: Arc::new(AIAgentActionResult { id: action_id, task_id: action.task_id, result: action_result, }), conversation_id, cancellation_reason: None, }); TryExecuteResult::ExecutedSync } } } pub fn can_autoexecute_action( &self, action: &AIAgentAction, conversation_id: AIConversationId, ctx: &mut ModelContext, ) -> bool { self.should_autoexecute( ExecuteActionInput { action, conversation_id, }, ctx, ) } pub fn cancel_running_async_action( &mut self, action_id: &AIAgentActionId, reason: Option, ctx: &mut ModelContext, ) { // A viewer should not be able to cancel an action. if self.is_shared_session_viewer() { return; } if let Some(running) = self.async_executing_actions.remove(action_id) { if running.is_shell_command_action() { self.shell_command_executor.update(ctx, |executor, ctx| { executor.cancel_execution(&running.action.id, ctx); }); } else if matches!(running.action.action, AIAgentActionType::SearchCodebase(..)) { self.search_codebase_executor.update(ctx, |executor, ctx| { executor.cancel_execution(&running.action.id, ctx); }); } ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction { result: Arc::new(AIAgentActionResult { id: running.action.id.clone(), task_id: running.action.task_id, result: running.action.action.cancelled_result(), }), conversation_id: running.conversation_id, cancellation_reason: reason, }); } } pub fn cancel_all_running_async_actions_for_conversation( &mut self, conversation_id: AIConversationId, reason: Option, ctx: &mut ModelContext, ) { let action_ids = self .async_executing_actions .iter() .filter_map(|(action_id, running)| { (running.conversation_id == conversation_id).then_some(action_id.clone()) }) .collect::>(); for action_id in action_ids { self.cancel_running_async_action(&action_id, reason, ctx); } } fn should_autoexecute(&self, input: ExecuteActionInput, ctx: &mut ModelContext) -> bool { if cfg!(feature = "bedrock_smoke_test") { return true; } 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)), AIAgentActionType::ReadMCPResource { .. } => self .read_mcp_resource_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::InitProject => true, AIAgentActionType::OpenCodeReview => true, AIAgentActionType::InsertCodeReviewComments { .. } => true, AIAgentActionType::SuggestNewConversation { .. } => self .suggest_new_conversation_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::SuggestPrompt { .. } => self .suggest_prompt_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::ReadDocuments(_) => self .read_documents_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::EditDocuments(_) => self .edit_documents_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::CreateDocuments(_) => self .create_documents_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::UseComputer(_) => self .use_computer_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::RequestComputerUse(_) => self .request_computer_use_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::ReadSkill(_) => self .read_skill_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::FetchConversation { .. } => self .fetch_conversation_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::StartAgent { .. } => self .start_agent_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::SendMessageToAgent { .. } => self .send_message_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), AIAgentActionType::AskUserQuestion { .. } => self .ask_user_question_executor .update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)), } } fn is_shared_session_viewer(&self) -> bool { self.terminal_model.lock().is_shared_session_viewer() } } impl Entity for BlocklistAIActionExecutor { type Event = BlocklistAIActionExecutorEvent; } pub enum BlocklistAIActionExecutorEvent { /// Emitted when an action is execution starts. ExecutingAction { action_id: AIAgentActionId, }, /// Emitted when an action has finished. FinishedAction { result: Arc, conversation_id: AIConversationId, /// The reason for cancellation, if this action was cancelled. cancellation_reason: Option, }, InitProject(AIAgentActionId), OpenCodeReview(AIAgentActionId), InsertCodeReviewComments { action_id: AIAgentActionId, repo_path: PathBuf, comments: Vec, base_branch: Option, }, } /// Per-file byte limit for [`read_local_file_context`]. Binary files larger /// than this are skipped; text files are truncated at this limit. #[cfg(feature = "local_fs")] const MAX_FILE_READ_BYTES: usize = 1_000_000; /// The results of a [`read_local_file_context`] call. #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] pub struct ReadFileContextResult { /// [`FileContext`] data for all files that could be read. pub file_contexts: Vec, /// Expected absolute paths of requested files that did not exist or could /// not be read (e.g. binary files that exceed the size limit). pub missing_files: Vec, } /// Reads the content of the given files at the given `FileLocations`. /// /// If any files do not exist, they are included in the `missing_files` field of the result. /// /// Binary files larger than the per-file byte limit are skipped and reported as oversized. /// Text files are truncated at the per-file limit via line streaming. /// If `max_file_bytes` is provided, it overrides the default per-file limit /// ([`MAX_FILE_READ_BYTES`]). Pass `None` to use the default. /// If `max_batch_bytes` is provided, the cumulative content of all files is capped at that /// budget; once exceeded, remaining files are reported as oversized. #[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] pub async fn read_local_file_context( file_names: &[FileLocations], current_working_directory: Option, shell: Option, max_file_bytes: Option, max_batch_bytes: Option, ) -> anyhow::Result { #[cfg(not(feature = "local_fs"))] return Err(anyhow::anyhow!( "Can't read files when not on a local filesystem" )); #[cfg(feature = "local_fs")] { let mut result = ReadFileContextResult { file_contexts: Vec::new(), missing_files: Vec::new(), }; let mut batch_bytes_remaining = max_batch_bytes; for file in file_names { let absolute_file_path = PathBuf::from(host_native_absolute_path( &file.name, &shell, ¤t_working_directory, )); let metadata = match async_fs::metadata(&absolute_file_path).await { Ok(m) => m, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { result .missing_files .push(absolute_file_path.to_string_lossy().to_string()); continue; } Err(e) => return Err(anyhow::anyhow!(e)), }; let last_modified = metadata.modified().ok(); let file_size = metadata.len() as usize; let path_str = absolute_file_path.to_string_lossy().to_string(); // Effective byte budget: the tighter of per-file and remaining batch. let per_file_limit = max_file_bytes.unwrap_or(MAX_FILE_READ_BYTES); let effective_max = match batch_bytes_remaining { Some(remaining) => per_file_limit.min(remaining), None => per_file_limit, }; // Decide text vs binary before opening the file. Extension-based // detection alone is wrong for extensionless text files (e.g. shell // scripts named `bundle`, `run`), so for the ambiguous case we fall // back to content-based inspection of the first chunk. The binary // path below still acts as a safety net via // `TextFileReadResult::NotText` if the text reader trips on invalid // UTF-8. if !should_read_as_binary(&absolute_file_path).await { match FileModel::read_text_file( &absolute_file_path, effective_max, &file.lines, last_modified, ) .await? { TextFileReadResult::Segments { segments, bytes_read, } => { if let Some(remaining) = &mut batch_bytes_remaining { *remaining = remaining.saturating_sub(bytes_read); } result .file_contexts .extend(segments.into_iter().map(|seg| FileContext { file_name: seg.file_name, content: AnyFileContent::StringContent(seg.content), line_range: seg.line_range, last_modified: seg.last_modified, line_count: seg.line_count, })); continue; } TextFileReadResult::NotText => { // Fall through to binary path below. } } } // Binary path (either detected as binary, or text reading failed). match read_binary_file_context( &absolute_file_path, effective_max, file_size, last_modified, ) .await? { BinaryFileReadResult::Context { file_context, bytes_read, } => { if let Some(remaining) = &mut batch_bytes_remaining { *remaining = remaining.saturating_sub(bytes_read); } result.file_contexts.push(file_context); } BinaryFileReadResult::Missing => result.missing_files.push(path_str), } } Ok(result) } } /// Returns `true` if the file at `path` should be read via the binary code /// path in [`read_local_file_context`], `false` if it should be read as text. /// /// Uses extension-based detection as a fast path and falls back to content /// inspection (reading the first 1 KiB) for extensionless files whose names /// don't match any known text or binary pattern. Without the content-based /// fallback, extensionless text files (e.g. shell scripts named `bundle`) /// would be incorrectly classified as binary and returned to the agent as /// raw bytes instead of UTF-8 text. #[cfg(feature = "local_fs")] async fn should_read_as_binary(path: &std::path::Path) -> bool { // Fast path: extension/filename clearly indicates text. if !is_binary_file(path) { return false; } // Fast path: file has a known binary extension (e.g. `.png`, `.exe`). if path.extension().is_some() { return true; } // Extensionless file with an unknown basename. Inspect the first chunk of // the file to decide. Treat open/read errors as binary so the binary path // takes over and reports a consistent error. is_file_content_binary_async(path).await } /// Async sibling of [`galaxy_util::file_type::is_file_content_binary`]. Reads /// the first 1 KiB of `path` asynchronously and returns `true` if the content /// looks binary according to [`is_buffer_binary`]. Returns `true` on any I/O /// error so callers default to the binary code path. Kept local to this /// module so `galaxy_util` doesn't need to grow an `async_fs` dependency. #[cfg(feature = "local_fs")] async fn is_file_content_binary_async(path: &std::path::Path) -> bool { const CHUNK_SIZE: usize = 1024; let Ok(mut file) = async_fs::File::open(path).await else { return true; }; let mut buffer = [0u8; CHUNK_SIZE]; let Ok(n) = file.read(&mut buffer).await else { return true; }; is_buffer_binary(&buffer[..n]) } #[cfg(feature = "local_fs")] enum BinaryFileReadResult { /// Successfully read as binary. Context { file_context: FileContext, bytes_read: usize, }, /// File doesn't exist, exceeds the size limit, or couldn't be processed. Missing, } /// Reads a binary file, applying image processing when applicable. #[cfg(feature = "local_fs")] async fn read_binary_file_context( path: &std::path::Path, max_bytes: usize, file_size: usize, last_modified: Option, ) -> anyhow::Result { if file_size > max_bytes { return Ok(BinaryFileReadResult::Missing); } let content = match read_file_as_binary(path).await { Ok(content) => content, Err(FileLoadError::DoesNotExist) => return Ok(BinaryFileReadResult::Missing), Err(FileLoadError::IOError(e)) => return Err(anyhow::anyhow!(e)), }; let mime_type = from_path(path).first_or_octet_stream().to_string(); let processed_content = if is_supported_image_mime_type(&mime_type) { match process_image_for_agent(&content) { ProcessImageResult::Success { data } => Some(data), ProcessImageResult::TooLarge => { log::warn!("Image file too large after processing: {}", path.display()); return Ok(BinaryFileReadResult::Missing); } ProcessImageResult::Error(err) => { log::warn!("Error processing image file {}: {err:?}", path.display()); return Ok(BinaryFileReadResult::Missing); } } } else { None }; let final_content = processed_content.unwrap_or(content); if final_content.len() > max_bytes { return Ok(BinaryFileReadResult::Missing); } let bytes_read = final_content.len(); Ok(BinaryFileReadResult::Context { file_context: FileContext::new( path.to_string_lossy().to_string(), AnyFileContent::BinaryContent(final_content), None, last_modified, ), bytes_read, }) } /// Returns true if the given path is a regular file on the session's filesystem. /// Runs a shell command on the session so it works for both local and remote sessions. async fn is_file_path(path: &str, session: &Session) -> bool { let command = if session.shell().shell_type() == ShellType::PowerShell { format!("if (Test-Path -PathType Leaf \"{path}\") {{ exit 0 }} else {{ exit 1 }}") } else { format!("test -f \"{path}\"") }; session .execute_command(&command, None, None, ExecuteCommandOptions::default()) .await .map(|output| output.success()) .unwrap_or(false) } /// Returns true if git is installed and the given path is in a git repository. async fn is_git_repository(absolute_path: &str, session: &Session) -> anyhow::Result { let git_command = format!("git -C \"{absolute_path}\" rev-parse"); let command_output = session .execute_command( git_command.as_str(), None, None, ExecuteCommandOptions::default(), ) .await?; Ok(command_output.success()) } fn get_server_output_id( conversation_id: AIConversationId, ctx: &mut AppContext, ) -> Option { BlocklistAIHistoryModel::as_ref(ctx) .conversation(&conversation_id)? .latest_exchange()? .output_status .server_output_id() } #[cfg(feature = "local_fs")] async fn read_file_as_binary(file_path: &std::path::Path) -> Result, FileLoadError> { if !FileModel::file_exists(file_path).await { return Err(FileLoadError::DoesNotExist); } async_fs::read(file_path).await.map_err(FileLoadError::from) } #[cfg(all(test, feature = "local_fs"))] #[path = "execute_tests.rs"] mod tests;