Improve local tool execution and shell output panes

This commit is contained in:
2026-08-28 10:20:16 -05:00
parent a69b927cc3
commit 88c1ef9716
23 changed files with 807 additions and 238 deletions
+70 -8
View File
@@ -24,10 +24,11 @@ pub(super) mod use_computer;
pub(super) mod wait_for_events;
use std::any::Any;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
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;
@@ -52,10 +53,11 @@ use galaxy_util::file_type::is_buffer_binary;
use galaxyui::r#async::{Spawnable, SpawnableOutput};
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use grep::GrepExecutor;
use instant::Instant;
#[cfg(feature = "local_fs")]
use mime_guess::from_path;
use notebooks::NotebookExecutor;
use parking_lot::FairMutex;
use parking_lot::{FairMutex, Mutex};
use read_documents::ReadDocumentsExecutor;
pub(super) use read_files::ReadFilesExecutor;
use read_mcp_resource::ReadMCPResourceExecutor;
@@ -96,7 +98,7 @@ use crate::ai::get_relevant_files::controller::GetRelevantFilesController;
use crate::ai::{agent::AnyFileContent, paths::host_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};
use crate::terminal::model::session::{ExecuteCommandOptions, Session, SessionId};
use crate::terminal::model_events::ModelEventDispatcher;
use crate::terminal::shell::ShellType;
use crate::terminal::{ShellLaunchData, TerminalModel};
@@ -146,6 +148,34 @@ pub(super) enum RunningActionPhase {
Parallel(ParallelExecutionPolicy),
}
const GIT_REPOSITORY_CACHE_TTL: Duration = Duration::from_secs(60);
type GitRepositoryCacheEntries = HashMap<(SessionId, String), (Instant, bool)>;
#[derive(Clone, Default)]
struct GitRepositoryCache {
entries: Arc<Mutex<GitRepositoryCacheEntries>>,
}
impl GitRepositoryCache {
fn get(&self, session_id: SessionId, path: &str) -> Option<bool> {
let key = (session_id, path.to_string());
let mut entries = self.entries.lock();
let (cached_at, is_repository) = entries.get(&key).copied()?;
if cached_at.elapsed() < GIT_REPOSITORY_CACHE_TTL {
Some(is_repository)
} else {
entries.remove(&key);
None
}
}
fn insert(&self, session_id: SessionId, path: String, is_repository: bool) {
let mut entries = self.entries.lock();
entries.retain(|_, (cached_at, _)| cached_at.elapsed() < GIT_REPOSITORY_CACHE_TTL);
entries.insert((session_id, path), (Instant::now(), is_repository));
}
}
#[derive(Debug, Clone, Copy)]
struct ExecuteActionInput<'a> {
action: &'a AIAgentAction,
@@ -355,10 +385,21 @@ 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 git_repository_cache = GitRepositoryCache::default();
let grep_executor = ctx.add_model(|_| {
GrepExecutor::new(
active_session.clone(),
terminal_view_id,
git_repository_cache.clone(),
)
});
let file_glob_executor = ctx.add_model(|_| {
FileGlobExecutor::new(
active_session.clone(),
terminal_view_id,
git_repository_cache,
)
});
let read_mcp_resource_executor = ctx
.add_model(|_| ReadMCPResourceExecutor::new(active_session.clone(), terminal_view_id));
let call_mcp_tool_executor =
@@ -1550,8 +1591,15 @@ fn build_is_git_repository_command(absolute_path: &str, shell_type: ShellType) -
}
/// 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.
/// Uses native metadata for compatible local sessions and a shell probe otherwise.
async fn is_file_path(path: &str, session: &Session) -> bool {
#[cfg(feature = "local_fs")]
if session.is_local() && !session.is_wsl() && !session.is_msys2() {
return async_fs::metadata(path)
.await
.is_ok_and(|metadata| metadata.is_file());
}
let command = build_is_file_path_command(path, session.shell().shell_type());
session
.execute_command(&command, None, None, ExecuteCommandOptions::default())
@@ -1574,6 +1622,20 @@ async fn is_git_repository(absolute_path: &str, session: &Session) -> anyhow::Re
Ok(command_output.success())
}
async fn is_git_repository_cached(
absolute_path: &str,
session: &Session,
cache: &GitRepositoryCache,
) -> anyhow::Result<bool> {
if let Some(is_repository) = cache.get(session.id(), absolute_path) {
return Ok(is_repository);
}
let is_repository = is_git_repository(absolute_path, session).await?;
cache.insert(session.id(), absolute_path.to_string(), is_repository);
Ok(is_repository)
}
fn get_server_output_id(
conversation_id: AIConversationId,
ctx: &mut AppContext,
@@ -26,13 +26,14 @@ 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,
get_server_output_id, is_git_repository_cached, ActionExecution, AnyActionExecution,
ExecuteActionInput, GitRepositoryCache, PreprocessActionInput,
};
pub struct FileGlobExecutor {
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
git_repository_cache: GitRepositoryCache,
}
fn log_file_glob_error(conversation_id: AIConversationId, ctx: &mut AppContext) {
@@ -41,10 +42,15 @@ fn log_file_glob_error(conversation_id: AIConversationId, ctx: &mut AppContext)
}
impl FileGlobExecutor {
pub fn new(active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
pub(super) fn new(
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
git_repository_cache: GitRepositoryCache,
) -> Self {
Self {
active_session,
terminal_view_id,
git_repository_cache,
}
}
@@ -134,11 +140,18 @@ impl FileGlobExecutor {
let patterns_clone = patterns.clone();
let conversation_id_clone = input.conversation_id;
let is_file_glob_v2 = is_file_glob_v2(&input);
let git_repository_cache = self.git_repository_cache.clone();
ActionExecution::new_async(
async move {
match run_file_glob(patterns_clone, absolute_path, session, shell_launch_data)
.with_timeout(FILE_GLOB_TIMEOUT)
.await
match run_file_glob(
patterns_clone,
absolute_path,
session,
shell_launch_data,
git_repository_cache,
)
.with_timeout(FILE_GLOB_TIMEOUT)
.await
{
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!("File glob operation timed out")),
@@ -204,6 +217,7 @@ async fn run_file_glob(
absolute_path: String,
session: Option<Arc<Session>>,
shell_launch_data: Option<ShellLaunchData>,
git_repository_cache: GitRepositoryCache,
) -> anyhow::Result<FileGlobV2Result> {
if patterns.is_empty() {
return Err(anyhow::anyhow!("No patterns provided to file_glob"));
@@ -213,12 +227,13 @@ async fn run_file_glob(
};
let shell_type = session.shell().shell_type();
let is_in_git_repo = is_git_repository(&absolute_path, session.as_ref())
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
let is_in_git_repo =
is_git_repository_cached(&absolute_path, session.as_ref(), &git_repository_cache)
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
if is_in_git_repo {
run_git_ls_files_command(
@@ -5,14 +5,14 @@ use std::sync::Arc;
use std::time::Duration;
use futures::future::BoxFuture;
use futures::FutureExt;
use futures::{FutureExt, StreamExt};
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::r#async::FutureExt as AsyncFutureExt;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::{
get_server_output_id, is_file_path, is_git_repository, ActionExecution, AnyActionExecution,
ExecuteActionInput, PreprocessActionInput,
get_server_output_id, is_file_path, is_git_repository_cached, ActionExecution,
AnyActionExecution, ExecuteActionInput, GitRepositoryCache, PreprocessActionInput,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::redaction::redact_secrets;
@@ -30,6 +30,8 @@ use crate::terminal::ShellLaunchData;
use crate::{send_telemetry_from_app_ctx, PrivacySettings, TelemetryEvent};
const GREP_TIMEOUT: Duration = Duration::from_secs(10);
const GREP_MAX_MATCHES: usize = 1_000;
const GREP_MAX_MATCHES_PER_FILE: usize = 50;
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
@@ -179,13 +181,19 @@ fn log_grep_error(
pub struct GrepExecutor {
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
git_repository_cache: GitRepositoryCache,
}
impl GrepExecutor {
pub fn new(active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
pub(super) fn new(
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
git_repository_cache: GitRepositoryCache,
) -> Self {
Self {
active_session,
terminal_view_id,
git_repository_cache,
}
}
@@ -264,11 +272,18 @@ impl GrepExecutor {
let absolute_path_clone = absolute_path.clone();
let working_directory_clone = current_working_directory.clone();
let conversation_id_clone = input.conversation_id;
let git_repository_cache = self.git_repository_cache.clone();
ActionExecution::new_async(
async move {
match run_grep(queries_clone, absolute_path, session, shell_launch_data)
.with_timeout(GREP_TIMEOUT)
.await
match run_grep(
queries_clone,
absolute_path,
session,
shell_launch_data,
git_repository_cache,
)
.with_timeout(GREP_TIMEOUT)
.await
{
Ok(result) => result,
Err(_) => Err(GrepError::new("Grep operation timed out".to_string())),
@@ -345,6 +360,7 @@ async fn run_grep(
absolute_path: String,
session: Option<Arc<Session>>,
shell_launch_data: Option<ShellLaunchData>,
git_repository_cache: GitRepositoryCache,
) -> Result<GrepResult, GrepError> {
if queries.is_empty() {
return Err(GrepError::new("No queries provided to grep".to_string()));
@@ -373,13 +389,13 @@ async fn run_grep(
Cow::Borrowed(absolute_path.as_str())
};
// TODO(CODE-239): Cache the result of this check.
let is_grep_in_git_repo = is_git_repository(&execute_directory, &session)
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
let is_grep_in_git_repo =
is_git_repository_cached(&execute_directory, &session, &git_repository_cache)
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
let shell_type = session.shell().shell_type();
// The most optimized tool to perform the search is `git grep`;
@@ -428,10 +444,13 @@ async fn run_grep(
#[cfg(not(target_family = "wasm"))]
async fn run_ripgrep(queries: &[String], absolute_path: String) -> Result<GrepResult, GrepError> {
let path = PathBuf::from(absolute_path);
let result = galaxy_ripgrep::search::search(queries, &[path], false, false).await;
let result = galaxy_ripgrep::search::search_streaming(queries, &[path], false, false);
match result {
Ok(matches) => {
let mut matches = matches.take(GREP_MAX_MATCHES + 1).collect::<Vec<_>>().await;
let truncated = matches.len() > GREP_MAX_MATCHES;
matches.truncate(GREP_MAX_MATCHES);
let mut files_map: HashMap<PathBuf, Vec<GrepLineMatch>> = HashMap::new();
for m in matches {
files_map
@@ -448,7 +467,10 @@ async fn run_ripgrep(queries: &[String], absolute_path: String) -> Result<GrepRe
matched_lines,
})
.collect();
Ok(GrepResult::Success { matched_files })
Ok(GrepResult::Success {
matched_files,
truncated,
})
}
Err(e) => Err(GrepError::new(format!("Ripgrep search failed: {e}"))),
}
@@ -482,7 +504,10 @@ async fn run_git_grep_command(
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map(|(matched_files, truncated)| GrepResult::Success {
matched_files,
truncated,
})
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(grep_command)
@@ -496,6 +521,7 @@ async fn run_git_grep_command(
// matches.
Ok(GrepResult::Success {
matched_files: vec![],
truncated: false,
})
} else {
Err(GrepError::new_for_non_zero_exit_code()
@@ -531,7 +557,10 @@ async fn run_grep_command(
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map(|(matched_files, truncated)| GrepResult::Success {
matched_files,
truncated,
})
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(grep_command)
@@ -545,6 +574,7 @@ async fn run_grep_command(
// matches.
Ok(GrepResult::Success {
matched_files: vec![],
truncated: false,
})
} else {
Err(GrepError::new_for_non_zero_exit_code()
@@ -580,7 +610,10 @@ async fn run_select_string_command(
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map(|(matched_files, truncated)| GrepResult::Success {
matched_files,
truncated,
})
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(select_string_command)
@@ -595,7 +628,9 @@ async fn run_select_string_command(
fn build_git_grep_command(queries: &[String], target_path: &str, shell_type: ShellType) -> String {
// This command works on all the shells we support (even PowerShell).
let mut grep_command = "git --no-pager grep --color=never --untracked -nIE".to_string();
let mut grep_command = format!(
"git --no-pager grep --color=never --untracked -nIE -m {GREP_MAX_MATCHES_PER_FILE}"
);
for query in queries {
// Queries can originate from model output and project instructions. Keep
// them as grep arguments so shell substitutions like $() are inert.
@@ -613,7 +648,8 @@ fn build_grep_command(queries: &[String], target_path: &str, shell_type: ShellTy
// * "-I" ignores binary files
// * "-H" prints file name headers
// * "-E" uses extended regex expressions
let mut grep_command = "grep --color=never -nrIHE --devices=skip".to_string();
let mut grep_command =
format!("grep --color=never -nrIHE --devices=skip -m {GREP_MAX_MATCHES_PER_FILE}");
for query in queries {
// Queries can originate from model output and project instructions. Keep
// them as grep arguments so shell substitutions like $() are inert.
@@ -627,7 +663,7 @@ fn build_select_string_command(queries: &[String], target_path: &str) -> String
// We enable the `-CaseSensitive` flag to match the default behavior of grep.
// TODO(CODE-239): Make this command more efficient when searching a file.
format!(
"Get-ChildItem -Path {} -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern {}",
"Get-ChildItem -Path {} -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern {} | Select-Object -First {}",
shell_quote_arg(target_path, ShellType::PowerShell),
queries
.iter()
@@ -635,7 +671,8 @@ fn build_select_string_command(queries: &[String], target_path: &str) -> String
// strings, so patterns must be single-quoted data arguments.
.map(|q| shell_quote_arg(q, ShellType::PowerShell))
.collect::<Vec<_>>()
.join(",")
.join(","),
GREP_MAX_MATCHES + 1,
)
}
@@ -648,10 +685,11 @@ fn parse_grep_output(
output: &str,
shell_launch_data: Option<ShellLaunchData>,
current_working_directory: Option<String>,
) -> anyhow::Result<Vec<GrepFileMatch>> {
) -> anyhow::Result<(Vec<GrepFileMatch>, bool)> {
let mut matched_files = HashMap::new();
let mut lines = output.trim().lines();
for line in output.trim().split("\n") {
for line in lines.by_ref().take(GREP_MAX_MATCHES) {
let mut parts = line.split(":");
let file = parts.next();
let line_number = parts.next();
@@ -677,17 +715,24 @@ fn parse_grep_output(
.push(GrepLineMatch { line_number });
}
Ok(matched_files
.into_iter()
.map(|(file, matched_lines)| GrepFileMatch {
file_path: host_native_absolute_path(
file,
&shell_launch_data,
&current_working_directory,
),
matched_lines,
})
.collect())
let truncated = lines.next().is_some()
|| matched_files
.values()
.any(|matched_lines| matched_lines.len() >= GREP_MAX_MATCHES_PER_FILE);
Ok((
matched_files
.into_iter()
.map(|(file, matched_lines)| GrepFileMatch {
file_path: host_native_absolute_path(
file,
&shell_launch_data,
&current_working_directory,
),
matched_lines,
})
.collect(),
truncated,
))
}
impl Entity for GrepExecutor {
@@ -81,7 +81,7 @@ fn build_git_grep_command_single_quotes_shell_substitution() {
assert_eq!(
command,
"git --no-pager grep --color=never --untracked -nIE -e '$(touch /tmp/warp-poc); `id`' '/tmp/repo path'"
"git --no-pager grep --color=never --untracked -nIE -m 50 -e '$(touch /tmp/warp-poc); `id`' '/tmp/repo path'"
);
}
@@ -93,7 +93,7 @@ fn build_grep_command_escapes_single_quotes() {
assert_eq!(
command,
r#"grep --color=never -nrIHE --devices=skip -e 'owner'"'"'s code' '/tmp/repo'"#
r#"grep --color=never -nrIHE --devices=skip -m 50 -e 'owner'"'"'s code' '/tmp/repo'"#
);
}
@@ -105,6 +105,23 @@ fn build_select_string_command_single_quotes_powershell_substitution() {
assert_eq!(
command,
r#"Get-ChildItem -Path 'C:\repo path' -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern '$(New-Item C:\pwn); ''literal'''"#
r#"Get-ChildItem -Path 'C:\repo path' -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern '$(New-Item C:\pwn); ''literal''' | Select-Object -First 1001"#
);
}
#[test]
fn parse_grep_output_reports_when_results_reach_the_per_file_limit() {
let output = (1..=GREP_MAX_MATCHES_PER_FILE)
.map(|line| format!("src/lib.rs:{line}:match"))
.collect::<Vec<_>>()
.join("\n");
let (matched_files, truncated) = parse_grep_output(&output, None, None).unwrap();
assert_eq!(matched_files.len(), 1);
assert_eq!(
matched_files[0].matched_lines.len(),
GREP_MAX_MATCHES_PER_FILE
);
assert!(truncated);
}
@@ -1,6 +1,6 @@
use std::path::{Path, PathBuf};
use futures::future::BoxFuture;
use futures::future::{join_all, BoxFuture};
use futures::FutureExt;
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
@@ -236,14 +236,24 @@ impl ReadFilesExecutor {
// Local path.
ActionExecution::Async {
execute_future: Box::pin(async move {
let result = read_local_file_context(
&locations,
current_working_directory,
shell,
None,
None,
)
.await?;
let reads = locations.iter().map(|location| {
read_local_file_context(
std::slice::from_ref(location),
current_working_directory.clone(),
shell.clone(),
None,
None,
)
});
let mut result = super::ReadFileContextResult {
file_contexts: Vec::new(),
missing_files: Vec::new(),
};
for file_result in join_all(reads).await {
let file_result = file_result?;
result.file_contexts.extend(file_result.file_contexts);
result.missing_files.extend(file_result.missing_files);
}
if result.missing_files.is_empty() {
Ok(ReadFilesResult::Success {
files: result.file_contexts,