Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,9 @@
"runtime_metrics": {
"type": "boolean"
},
"search_file": {
"type": "boolean"
},
"search_tool": {
"type": "boolean"
},
Expand Down Expand Up @@ -6150,6 +6153,9 @@
"runtime_metrics": {
"type": "boolean"
},
"search_file": {
"type": "boolean"
},
"search_tool": {
"type": "boolean"
},
Expand Down
21 changes: 17 additions & 4 deletions codex-rs/core/src/environment_selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ struct ResolvedEnvironment {
temporary_directories: Option<Vec<PathUri>>,
shell_snapshot: ShellSnapshotTask,
shell_snapshot_v2_supported: bool,
file_search_supported: bool,
installed_config: Option<EnvironmentConfig>,
}

Expand Down Expand Up @@ -264,6 +265,7 @@ impl ThreadEnvironments {
temporary_directories: environment.temporary_directories,
shell_snapshot: environment.shell_snapshot,
shell_snapshot_v2_supported: environment.shell_snapshot_v2_supported,
file_search_supported: environment.file_search_supported,
installed_config: None,
}))
.boxed()
Expand Down Expand Up @@ -628,13 +630,20 @@ impl ThreadEnvironments {
// Resolve the attachment only after both prerequisites are ready.
let ((), installed_config) = tokio::try_join!(connection_ready, configuration_ready)?;
let executor_platform_os;
let (shell, user_home_dir, temporary_dirs, snapshot_v2) = if environment.is_remote() {
let (
shell,
user_home_dir,
temporary_directories,
shell_snapshot_v2_supported,
file_search_supported,
) = if environment.is_remote() {
match environment.info().await {
Ok(info) => {
executor_platform_os = info.platform_os;
let user_home_dir = info.user_home_dir;
let temporary_directories = info.temporary_directories;
let shell_snapshot_v2_supported = info.capabilities.shell_snapshot_v2;
let file_search_supported = info.capabilities.file_search;
let shell = match Shell::from_environment_shell_info(info.shell) {
Ok(shell) => Some(shell),
Err(err) => {
Expand All @@ -649,12 +658,13 @@ impl ThreadEnvironments {
user_home_dir,
temporary_directories,
shell_snapshot_v2_supported,
file_search_supported,
)
}
Err(err) => {
executor_platform_os = None;
tracing::warn!("failed to get info for environment `{environment_id}`: {err}");
(None, None, None, false)
(None, None, None, false, false)
}
}
} else {
Expand All @@ -664,6 +674,7 @@ impl ThreadEnvironments {
PathUri::from_host_native_path("~").ok(),
Some(EnvironmentInfo::local_temporary_directories()),
cfg!(unix),
true,
)
};
let task = shell_snapshot
Expand All @@ -678,9 +689,10 @@ impl ThreadEnvironments {
shell,
user_home_dir,
executor_platform_os,
temporary_directories: temporary_dirs,
temporary_directories,
shell_snapshot: task,
shell_snapshot_v2_supported: snapshot_v2,
shell_snapshot_v2_supported,
file_search_supported,
installed_config,
})
}
Expand Down Expand Up @@ -759,6 +771,7 @@ impl TurnEnvironmentState {
turn_environment.shell_snapshot = environment.shell_snapshot;
turn_environment.shell_snapshot_v2_supported =
environment.shell_snapshot_v2_supported;
turn_environment.file_search_supported = environment.file_search_supported;
turn_environment.user_home_dir = environment.user_home_dir;
turn_environment.temporary_directories = environment.temporary_directories;
Some(Self::Ready(turn_environment))
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/core/src/session/turn_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ pub(crate) struct TurnEnvironment {
pub(crate) executor_platform_os: Option<String>,
pub(crate) shell_snapshot: ShellSnapshotTask,
pub(crate) shell_snapshot_v2_supported: bool,
pub(crate) file_search_supported: bool,
}

impl TurnEnvironment {
Expand All @@ -67,6 +68,7 @@ impl TurnEnvironment {
shell: Option<shell::Shell>,
) -> Self {
debug_assert!(matches!(selection.config, EnvironmentConfigState::Ready(_)));
let file_search_supported = !environment.is_remote();
Self {
selection,
config_origin,
Expand All @@ -77,6 +79,7 @@ impl TurnEnvironment {
executor_platform_os: None,
shell_snapshot: futures::future::ready(None).boxed().shared(),
shell_snapshot_v2_supported: false,
file_search_supported,
}
}

Expand Down
4 changes: 4 additions & 0 deletions codex-rs/core/src/tools/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ mod request_plugin_install;
pub(crate) mod request_plugin_install_spec;
mod request_user_input;
pub(crate) mod request_user_input_spec;
mod search_file;
mod search_file_formatter;
pub(crate) mod search_file_spec;
mod send_user_message_async;
pub(crate) mod shell_spec;
mod sleep;
Expand Down Expand Up @@ -68,6 +71,7 @@ pub use plan::PlanHandler;
pub use request_permissions::RequestPermissionsHandler;
pub use request_plugin_install::RequestPluginInstallHandler;
pub use request_user_input::RequestUserInputHandler;
pub(crate) use search_file::SearchFileHandler;
pub use send_user_message_async::SendUserMessageAsyncHandler;
pub use sleep::SleepHandler;
pub use test_sync::TestSyncHandler;
Expand Down
209 changes: 209 additions & 0 deletions codex-rs/core/src/tools/handlers/search_file.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::resolve_tool_environment;
use crate::tools::handlers::search_file_formatter::format_search_file_error;
use crate::tools::handlers::search_file_formatter::format_search_file_output;
use crate::tools::handlers::search_file_spec::SearchFileToolOptions;
use crate::tools::handlers::search_file_spec::create_search_file_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_exec_server::FileSearchCaseMode;
use codex_exec_server::FileSearchMode;
use codex_exec_server::FileSearchOptions;
use codex_file_system::MAX_FILE_SEARCH_RESULTS;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde::Deserialize;

const DEFAULT_SEARCH_RESULTS: usize = 100;
const DEFAULT_LIST_RESULTS: usize = 200;

pub(crate) struct SearchFileHandler {
options: SearchFileToolOptions,
}

impl SearchFileHandler {
pub(crate) fn new(options: SearchFileToolOptions) -> Self {
Self { options }
}
}

#[derive(Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum SearchFileModeArg {
Keyword,
Regex,
List,
}

#[derive(Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum SearchFileCaseModeArg {
Sensitive,
Insensitive,
}

#[derive(Deserialize)]
struct SearchFileArgs {
mode: SearchFileModeArg,
path: String,
query: Option<String>,
case_mode: Option<SearchFileCaseModeArg>,
#[serde(default = "default_recursive")]
recursive: bool,
#[serde(default)]
include: Vec<String>,
#[serde(default)]
exclude: Vec<String>,
#[serde(default)]
include_ignored: bool,
max_results: Option<usize>,
environment_id: Option<String>,
}

fn default_recursive() -> bool {
true
}

impl ToolExecutor<ToolInvocation> for SearchFileHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain("search_file")
}

fn spec(&self) -> ToolSpec {
create_search_file_tool(self.options)
}

fn supports_parallel_tool_calls(&self) -> bool {
true
}

fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
where
ToolInvocation: 'a,
{
Box::pin(async move {
let ToolPayload::Function { arguments } = &invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"search_file handler received unsupported payload".to_string(),
));
};
let args: SearchFileArgs =
parse_arguments(arguments).map_err(bound_model_visible_error)?;
let options = validated_options(&args).map_err(bound_model_visible_error)?;
let Some(environment) = resolve_tool_environment(
&invocation.step_context.environments,
args.environment_id.as_deref(),
)
.map_err(bound_model_visible_error)?
else {
return Err(FunctionCallError::RespondToModel(
"search_file is unavailable in this session".to_string(),
));
};
if !environment.file_search_supported {
return Err(model_visible_error(format!(
"search_file is not supported by environment `{}`",
environment.selection.environment_id
)));
}
let path = environment.cwd().join(&args.path).map_err(|error| {
model_visible_error(format!(
"unable to resolve search path `{}` against environment cwd `{}`: {error}",
args.path,
environment.cwd()
))
})?;
let sandbox = environment.sandbox_context(/*additional_permissions*/ None);
let outcome = environment
.environment
.get_filesystem()
.search(&path, options, Some(&sandbox))
.await
.map_err(|error| model_visible_error(error.to_string()))?;
Ok(boxed_tool_output(FunctionToolOutput::from_text(
format_search_file_output(&path, &outcome),
Some(true),
)))
})
}
}

fn model_visible_error(message: impl Into<String>) -> FunctionCallError {
FunctionCallError::RespondToModel(format_search_file_error(message))
}

fn bound_model_visible_error(error: FunctionCallError) -> FunctionCallError {
match error {
FunctionCallError::RespondToModel(message) => model_visible_error(message),
FunctionCallError::Fatal(message) => FunctionCallError::Fatal(message),
}
}

fn validated_options(args: &SearchFileArgs) -> Result<FileSearchOptions, FunctionCallError> {
let (mode, query, case_mode, default_max_results) = match args.mode {
SearchFileModeArg::Keyword | SearchFileModeArg::Regex => {
let query = args
.query
.clone()
.filter(|query| !query.is_empty())
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"query is required for keyword and regex modes".to_string(),
)
})?;
let case_mode = args.case_mode.ok_or_else(|| {
FunctionCallError::RespondToModel(
"case_mode is required for keyword and regex modes".to_string(),
)
})?;
(
match args.mode {
SearchFileModeArg::Keyword => FileSearchMode::Keyword,
SearchFileModeArg::Regex => FileSearchMode::Regex,
SearchFileModeArg::List => unreachable!(),
},
Some(query),
Some(match case_mode {
SearchFileCaseModeArg::Sensitive => FileSearchCaseMode::Sensitive,
SearchFileCaseModeArg::Insensitive => FileSearchCaseMode::Insensitive,
}),
DEFAULT_SEARCH_RESULTS,
)
}
SearchFileModeArg::List => {
if args.query.is_some() || args.case_mode.is_some() {
return Err(FunctionCallError::RespondToModel(
"query and case_mode are not valid for list mode".to_string(),
));
}
(FileSearchMode::List, None, None, DEFAULT_LIST_RESULTS)
}
};
let max_results = args.max_results.unwrap_or(default_max_results);
if !(1..=MAX_FILE_SEARCH_RESULTS).contains(&max_results) {
return Err(FunctionCallError::RespondToModel(format!(
"max_results must be between 1 and {MAX_FILE_SEARCH_RESULTS}"
)));
}
Ok(FileSearchOptions {
mode,
query,
case_mode,
recursive: args.recursive,
include: args.include.clone(),
exclude: args.exclude.clone(),
include_ignored: args.include_ignored,
max_results,
})
}

impl CoreToolRuntime for SearchFileHandler {}

#[cfg(test)]
#[path = "search_file_tests.rs"]
mod tests;
Loading