codex-tools: extract local host tool specs (#16138)

## Why

`core/src/tools/spec.rs` still bundled a set of pure local-host tool
builders with the orchestration that actually decides when those tools
are exposed and which handlers back them. That made `codex-core`
responsible for JSON/tool-shape construction that does not depend on
session state, and it kept the `codex-tools` migration from taking a
meaningfully larger bite out of `spec.rs`.

This PR moves that reusable spec-building layer into `codex-tools` while
leaving feature gating, handler registration, and runtime-coupled
descriptions in `codex-core`.

## What changed

- added `codex-rs/tools/src/local_tool.rs` for the pure builders for
`exec_command`, `write_stdin`, `shell`, `shell_command`, and
`request_permissions`
- added `codex-rs/tools/src/view_image.rs` for the `view_image` tool
spec and output schema so the extracted modules stay right-sized
- rewired `codex-rs/core/src/tools/spec.rs` to call those extracted
builders instead of constructing these specs inline
- kept the `request_permissions` description source in `codex-core`,
with `codex-tools` taking the description as input so the crate boundary
does not grow a dependency on handler/runtime code
- moved the direct constructor coverage for this slice from
`codex-rs/core/src/tools/spec_tests.rs` into
`codex-rs/tools/src/local_tool_tests.rs` and
`codex-rs/tools/src/view_image_tests.rs`
- updated `codex-rs/tools/README.md` to reflect that `codex-tools` now
owns this local-host spec layer

## Test plan

- `CARGO_TARGET_DIR=/tmp/codex-tools-local-host cargo test -p
codex-tools`
- `CARGO_TARGET_DIR=/tmp/codex-core-local-tools cargo test -p codex-core
--lib tools::spec::`
- `just argument-comment-lint`

## References

- #15923
- #15928
- #15944
- #15953
- #16031
- #16047
- #16129
- #16132
This commit is contained in:
Michael Bolin
2026-03-28 16:33:58 -07:00
committed by GitHub
parent 46b653e73c
commit 4e119a3b38
8 changed files with 1089 additions and 703 deletions

View File

@@ -4,10 +4,12 @@
mod code_mode;
mod dynamic_tool;
mod json_schema;
mod local_tool;
mod mcp_tool;
mod responses_api;
mod tool_definition;
mod tool_spec;
mod view_image;
pub use code_mode::augment_tool_spec_for_code_mode;
pub use code_mode::tool_spec_to_code_mode_tool_definition;
@@ -15,6 +17,13 @@ pub use dynamic_tool::parse_dynamic_tool;
pub use json_schema::AdditionalProperties;
pub use json_schema::JsonSchema;
pub use json_schema::parse_tool_input_schema;
pub use local_tool::CommandToolOptions;
pub use local_tool::ShellToolOptions;
pub use local_tool::create_exec_command_tool;
pub use local_tool::create_request_permissions_tool;
pub use local_tool::create_shell_command_tool;
pub use local_tool::create_shell_tool;
pub use local_tool::create_write_stdin_tool;
pub use mcp_tool::mcp_call_tool_result_output_schema;
pub use mcp_tool::parse_mcp_tool;
pub use responses_api::FreeformTool;
@@ -33,3 +42,5 @@ pub use tool_spec::ResponsesApiWebSearchFilters;
pub use tool_spec::ResponsesApiWebSearchUserLocation;
pub use tool_spec::ToolSpec;
pub use tool_spec::create_tools_json_for_responses_api;
pub use view_image::ViewImageToolOptions;
pub use view_image::create_view_image_tool;

View File

@@ -0,0 +1,463 @@
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::ToolSpec;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommandToolOptions {
pub allow_login_shell: bool,
pub exec_permission_approvals_enabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShellToolOptions {
pub exec_permission_approvals_enabled: bool,
}
pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([
(
"cmd".to_string(),
JsonSchema::String {
description: Some("Shell command to execute.".to_string()),
},
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some(
"Optional working directory to run the command in; defaults to the turn cwd."
.to_string(),
),
},
),
(
"shell".to_string(),
JsonSchema::String {
description: Some(
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
),
},
),
(
"tty".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
.to_string(),
),
},
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
),
},
),
(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
),
},
),
]);
if options.allow_login_shell {
properties.insert(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
),
},
);
}
properties.extend(create_approval_parameters(
options.exec_permission_approvals_enabled,
));
ToolSpec::Function(ResponsesApiTool {
name: "exec_command".to_string(),
description: if cfg!(windows) {
format!(
"Runs a command in a PTY, returning output or a session ID for ongoing interaction.\n\n{}",
windows_destructive_filesystem_guidance()
)
} else {
"Runs a command in a PTY, returning output or a session ID for ongoing interaction."
.to_string()
},
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["cmd".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(unified_exec_output_schema()),
})
}
pub fn create_write_stdin_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"session_id".to_string(),
JsonSchema::Number {
description: Some("Identifier of the running unified exec session.".to_string()),
},
),
(
"chars".to_string(),
JsonSchema::String {
description: Some("Bytes to write to stdin (may be empty to poll).".to_string()),
},
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
),
},
),
(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "write_stdin".to_string(),
description:
"Writes characters to an existing unified exec session and returns recent output."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["session_id".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(unified_exec_output_schema()),
})
}
pub fn create_shell_tool(options: ShellToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("The command to execute".to_string()),
},
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
),
]);
properties.extend(create_approval_parameters(
options.exec_permission_approvals_enabled,
));
let description = if cfg!(windows) {
format!(
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
Examples of valid command strings:
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"]
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]
{}"#,
windows_destructive_filesystem_guidance()
)
} else {
r#"Runs a shell command and returns its output.
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
ToolSpec::Function(ResponsesApiTool {
name: "shell".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::String {
description: Some(
"The shell script to execute in the user's default shell".to_string(),
),
},
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
),
]);
if options.allow_login_shell {
properties.insert(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with login shell semantics. Defaults to true."
.to_string(),
),
},
);
}
properties.extend(create_approval_parameters(
options.exec_permission_approvals_enabled,
));
let description = if cfg!(windows) {
format!(
r#"Runs a Powershell command (Windows) and returns its output.
Examples of valid command strings:
- ls -a (show hidden): "Get-ChildItem -Force"
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
- ps aux | grep python: "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -"
{}"#,
windows_destructive_filesystem_guidance()
)
} else {
r#"Runs a shell command and returns its output.
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
ToolSpec::Function(ResponsesApiTool {
name: "shell_command".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
pub fn create_request_permissions_tool(description: String) -> ToolSpec {
let properties = BTreeMap::from([
(
"reason".to_string(),
JsonSchema::String {
description: Some(
"Optional short explanation for why additional permissions are needed."
.to_string(),
),
},
),
("permissions".to_string(), permission_profile_schema()),
]);
ToolSpec::Function(ResponsesApiTool {
name: "request_permissions".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["permissions".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
fn unified_exec_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"chunk_id": {
"type": "string",
"description": "Chunk identifier included when the response reports one."
},
"wall_time_seconds": {
"type": "number",
"description": "Elapsed wall time spent waiting for output in seconds."
},
"exit_code": {
"type": "number",
"description": "Process exit code when the command finished during this call."
},
"session_id": {
"type": "number",
"description": "Session identifier to pass to write_stdin when the process is still running."
},
"original_token_count": {
"type": "number",
"description": "Approximate token count before output truncation."
},
"output": {
"type": "string",
"description": "Command output text, possibly truncated."
}
},
"required": ["wall_time_seconds", "output"],
"additionalProperties": false
})
}
fn create_approval_parameters(
exec_permission_approvals_enabled: bool,
) -> BTreeMap<String, JsonSchema> {
let mut properties = BTreeMap::from([
(
"sandbox_permissions".to_string(),
JsonSchema::String {
description: Some(
if exec_permission_approvals_enabled {
"Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem or network permissions (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
} else {
"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
}
.to_string(),
),
},
),
(
"justification".to_string(),
JsonSchema::String {
description: Some(
r#"Only set if sandbox_permissions is \"require_escalated\".
Request approval from the user to run this command outside the sandbox.
Phrased as a simple question that summarizes the purpose of the
command as it relates to the task at hand - e.g. 'Do you want to
fetch and pull the latest version of this git branch?'"#
.to_string(),
),
},
),
(
"prefix_rule".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
r#"Only specify when sandbox_permissions is `require_escalated`.
Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future.
Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."#.to_string(),
),
},
),
]);
if exec_permission_approvals_enabled {
properties.insert(
"additional_permissions".to_string(),
permission_profile_schema(),
);
}
properties
}
fn permission_profile_schema() -> JsonSchema {
JsonSchema::Object {
properties: BTreeMap::from([
("network".to_string(), network_permissions_schema()),
("file_system".to_string(), file_system_permissions_schema()),
]),
required: None,
additional_properties: Some(false.into()),
}
}
fn network_permissions_schema() -> JsonSchema {
JsonSchema::Object {
properties: BTreeMap::from([(
"enabled".to_string(),
JsonSchema::Boolean {
description: Some("Set to true to request network access.".to_string()),
},
)]),
required: None,
additional_properties: Some(false.into()),
}
}
fn file_system_permissions_schema() -> JsonSchema {
JsonSchema::Object {
properties: BTreeMap::from([
(
"read".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("Absolute paths to grant read access to.".to_string()),
},
),
(
"write".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("Absolute paths to grant write access to.".to_string()),
},
),
]),
required: None,
additional_properties: Some(false.into()),
}
}
fn windows_destructive_filesystem_guidance() -> &'static str {
r#"Windows safety rules:
- Do not compose destructive filesystem commands across shells. Do not enumerate paths in PowerShell and then pass them to `cmd /c`, batch builtins, or another shell for deletion or moving. Use one shell end-to-end, prefer native PowerShell cmdlets such as `Remove-Item` / `Move-Item` with `-LiteralPath`, and avoid string-built shell commands for file operations.
- Before any recursive delete or move on Windows, verify the resolved absolute target paths stay within the intended workspace or explicitly named target directory. Never issue a recursive delete or move against a computed path if the final target has not been checked."#
}
#[cfg(test)]
#[path = "local_tool_tests.rs"]
mod tests;

View File

@@ -0,0 +1,442 @@
use super::*;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
fn windows_shell_safety_description() -> String {
format!("\n\n{}", windows_destructive_filesystem_guidance())
}
#[test]
fn shell_tool_matches_expected_spec() {
let tool = create_shell_tool(ShellToolOptions {
exec_permission_approvals_enabled: false,
});
let description = if cfg!(windows) {
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
Examples of valid command strings:
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"]
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]"#
.to_string()
+ &windows_shell_safety_description()
} else {
r#"Runs a shell command and returns its output.
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
let properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("The command to execute".to_string()),
},
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
),
(
"sandbox_permissions".to_string(),
JsonSchema::String {
description: Some(
"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
.to_string(),
),
},
),
(
"justification".to_string(),
JsonSchema::String {
description: Some(
r#"Only set if sandbox_permissions is \"require_escalated\".
Request approval from the user to run this command outside the sandbox.
Phrased as a simple question that summarizes the purpose of the
command as it relates to the task at hand - e.g. 'Do you want to
fetch and pull the latest version of this git branch?'"#
.to_string(),
),
},
),
(
"prefix_rule".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
r#"Only specify when sandbox_permissions is `require_escalated`.
Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future.
Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."#
.to_string(),
),
},
),
]);
assert_eq!(
tool,
ToolSpec::Function(ResponsesApiTool {
name: "shell".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
);
}
#[test]
fn exec_command_tool_matches_expected_spec() {
let tool = create_exec_command_tool(CommandToolOptions {
allow_login_shell: true,
exec_permission_approvals_enabled: false,
});
let description = if cfg!(windows) {
format!(
"Runs a command in a PTY, returning output or a session ID for ongoing interaction.{}",
windows_shell_safety_description()
)
} else {
"Runs a command in a PTY, returning output or a session ID for ongoing interaction."
.to_string()
};
let mut properties = BTreeMap::from([
(
"cmd".to_string(),
JsonSchema::String {
description: Some("Shell command to execute.".to_string()),
},
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some(
"Optional working directory to run the command in; defaults to the turn cwd."
.to_string(),
),
},
),
(
"shell".to_string(),
JsonSchema::String {
description: Some(
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
),
},
),
(
"tty".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
.to_string(),
),
},
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
),
},
),
(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
),
},
),
(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
),
},
),
]);
properties.extend(create_approval_parameters(
/*exec_permission_approvals_enabled*/ false,
));
assert_eq!(
tool,
ToolSpec::Function(ResponsesApiTool {
name: "exec_command".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["cmd".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(unified_exec_output_schema()),
})
);
}
#[test]
fn write_stdin_tool_matches_expected_spec() {
let tool = create_write_stdin_tool();
let properties = BTreeMap::from([
(
"session_id".to_string(),
JsonSchema::Number {
description: Some("Identifier of the running unified exec session.".to_string()),
},
),
(
"chars".to_string(),
JsonSchema::String {
description: Some("Bytes to write to stdin (may be empty to poll).".to_string()),
},
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
),
},
),
(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
),
},
),
]);
assert_eq!(
tool,
ToolSpec::Function(ResponsesApiTool {
name: "write_stdin".to_string(),
description:
"Writes characters to an existing unified exec session and returns recent output."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["session_id".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(unified_exec_output_schema()),
})
);
}
#[test]
fn shell_tool_with_request_permission_includes_additional_permissions() {
let tool = create_shell_tool(ShellToolOptions {
exec_permission_approvals_enabled: true,
});
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("The command to execute".to_string()),
},
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
),
]);
properties.extend(create_approval_parameters(
/*exec_permission_approvals_enabled*/ true,
));
let description = if cfg!(windows) {
format!(
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
Examples of valid command strings:
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"]
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]
{}"#,
windows_destructive_filesystem_guidance()
)
} else {
r#"Runs a shell command and returns its output.
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
assert_eq!(
tool,
ToolSpec::Function(ResponsesApiTool {
name: "shell".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
);
}
#[test]
fn request_permissions_tool_includes_full_permission_schema() {
let tool =
create_request_permissions_tool("Request extra permissions for this turn.".to_string());
let properties = BTreeMap::from([
(
"reason".to_string(),
JsonSchema::String {
description: Some(
"Optional short explanation for why additional permissions are needed."
.to_string(),
),
},
),
("permissions".to_string(), permission_profile_schema()),
]);
assert_eq!(
tool,
ToolSpec::Function(ResponsesApiTool {
name: "request_permissions".to_string(),
description: "Request extra permissions for this turn.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["permissions".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
);
}
#[test]
fn shell_command_tool_matches_expected_spec() {
let tool = create_shell_command_tool(CommandToolOptions {
allow_login_shell: true,
exec_permission_approvals_enabled: false,
});
let description = if cfg!(windows) {
r#"Runs a Powershell command (Windows) and returns its output.
Examples of valid command strings:
- ls -a (show hidden): "Get-ChildItem -Force"
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
- ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -""#
.to_string()
+ &windows_shell_safety_description()
} else {
r#"Runs a shell command and returns its output.
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::String {
description: Some(
"The shell script to execute in the user's default shell".to_string(),
),
},
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
),
(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with login shell semantics. Defaults to true."
.to_string(),
),
},
),
]);
properties.extend(create_approval_parameters(
/*exec_permission_approvals_enabled*/ false,
));
assert_eq!(
tool,
ToolSpec::Function(ResponsesApiTool {
name: "shell_command".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
);
}

View File

@@ -0,0 +1,67 @@
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::ToolSpec;
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ViewImageToolOptions {
pub can_request_original_image_detail: bool,
}
pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([(
"path".to_string(),
JsonSchema::String {
description: Some("Local filesystem path to an image file".to_string()),
},
)]);
if options.can_request_original_image_detail {
properties.insert(
"detail".to_string(),
JsonSchema::String {
description: Some(
"Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(),
),
},
);
}
ToolSpec::Function(ResponsesApiTool {
name: VIEW_IMAGE_TOOL_NAME.to_string(),
description: "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags)."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["path".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(view_image_output_schema()),
})
}
fn view_image_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"image_url": {
"type": "string",
"description": "Data URL for the loaded image."
},
"detail": {
"type": ["string", "null"],
"description": "Image detail hint returned by view_image. Returns `original` when original resolution is preserved, otherwise `null`."
}
},
"required": ["image_url", "detail"],
"additionalProperties": false
})
}
#[cfg(test)]
#[path = "view_image_tests.rs"]
mod tests;

View File

@@ -0,0 +1,67 @@
use super::*;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn view_image_tool_omits_detail_without_original_detail_feature() {
assert_eq!(
create_view_image_tool(ViewImageToolOptions {
can_request_original_image_detail: false,
}),
ToolSpec::Function(ResponsesApiTool {
name: "view_image".to_string(),
description: "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags)."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"path".to_string(),
JsonSchema::String {
description: Some("Local filesystem path to an image file".to_string()),
},
)]),
required: Some(vec!["path".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(view_image_output_schema()),
})
);
}
#[test]
fn view_image_tool_includes_detail_with_original_detail_feature() {
assert_eq!(
create_view_image_tool(ViewImageToolOptions {
can_request_original_image_detail: true,
}),
ToolSpec::Function(ResponsesApiTool {
name: "view_image".to_string(),
description: "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags)."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
(
"detail".to_string(),
JsonSchema::String {
description: Some(
"Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(),
),
},
),
(
"path".to_string(),
JsonSchema::String {
description: Some("Local filesystem path to an image file".to_string()),
},
),
]),
required: Some(vec!["path".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(view_image_output_schema()),
})
);
}