Compare commits

...

9 Commits

Author SHA1 Message Date
Michael Bolin
09cc0984e6 codex-tools: extract collaboration tool specs 2026-03-28 17:04:07 -07:00
Michael Bolin
4e119a3b38 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
2026-03-28 16:33:58 -07:00
Eric Traut
46b653e73c Fix skills picker scrolling in tui app server (#16109)
Fixes #16091.

The app-server TUI was truncating the filtered mention candidate list to
`MAX_POPUP_ROWS`, so the `$` skills picker only exposed the first 8
matches. That made it look like many skills were missing and prevented
keyboard navigation beyond the first page, even though direct
`$skill-name` insertion still worked.

Testing: I manually verified the regression and confirmed the fix.
2026-03-28 17:22:25 -06:00
Michael Bolin
f7ef9599ed exec: make review-policy tests hermetic (#16137)
## Why

`thread_start_params_from_config()` is supposed to forward the effective
`approvals_reviewer` into the app-server request, but these tests were
constructing that config through `ConfigBuilder::build()`, which also
loads ambient system and managed config layers. On machines with an
admin or host-level reviewer override, the manual-only case could
inherit `guardian_subagent` and fail even though the exec-side mapping
was correct.

## What changed

- Set `approvals_reviewer` explicitly via `harness_overrides` in the two
`thread_start_params_*review_policy*` tests in
`codex-rs/exec/src/lib.rs`.
- Removed the dependence on default config resolution and temp
`config.toml` writes so the tests exercise only the reviewer-to-request
mapping in `codex-exec`.

## Testing

- `cargo test -p codex-exec`
2026-03-28 23:01:04 +00:00
Michael Bolin
a16a9109d7 ci: use BuildBuddy for rust-ci-full non-Windows argument-comment-lint (#16136)
## Why

PR #16130 fixed the Windows `argument-comment-lint` regression in
`rust-ci-full`, but the next `main` runs still left the Linux and macOS
lint legs timing out.

In [run
23695263729](https://github.com/openai/codex/actions/runs/23695263729),
both non-Windows `argument-comment-lint` jobs were cancelled almost
exactly 30 minutes after they started. The remaining workflow difference
versus `rust-ci.yml` was that `rust-ci-full` did not pass
`BUILDBUDDY_API_KEY` into the non-Windows Bazel lint step, so
`run-bazel-ci.sh` fell back to local Bazel configuration instead of
using the faster remote-backed path available on `main`.

## What changed

- passed `BUILDBUDDY_API_KEY` to the non-Windows `rust-ci-full`
`argument-comment-lint` Bazel step
- left the Windows packaged-wrapper path from #16130 unchanged
- kept the change scoped to `rust-ci-full.yml`

## Test plan

- loaded `.github/workflows/rust-ci-full.yml` and
`.github/workflows/rust-ci.yml` with `python3` + `yaml.safe_load(...)`
- inspected run `23695263729` and confirmed `Argument comment lint -
Linux` and `Argument comment lint - macOS` were cancelled about 30
minutes after start
- verified the updated `rust-ci-full` step now matches the non-Windows
secret wiring already present in `rust-ci.yml`

## References

- #16130
- #16106
2026-03-28 15:36:01 -07:00
Michael Bolin
2238c16a91 codex-tools: extract code mode tool spec adapters (#16132)
## Why

The longer-term `codex-tools` migration is to move pure tool-definition
and tool-spec plumbing out of `codex-core` while leaving session- and
runtime-coupled orchestration behind.

The remaining code-mode adapter layer in
`core/src/tools/code_mode_description.rs` was a good next extraction
seam because it only transformed `ToolSpec` values for code mode and
already delegated the low-level description rendering to
`codex-code-mode`.

## What Changed

- added `codex-rs/tools/src/code_mode.rs` with
`augment_tool_spec_for_code_mode()` and
`tool_spec_to_code_mode_tool_definition()`
- added focused unit coverage in `codex-rs/tools/src/code_mode_tests.rs`
- rewired `core/src/tools/spec.rs` and `core/src/tools/code_mode/mod.rs`
to use the extracted adapters from `codex-tools`
- removed the old `core/src/tools/code_mode_description.rs` shim and its
test file from `codex-core`
- added the `codex-code-mode` dependency to `codex-tools`, updated
`Cargo.lock`, and refreshed the `codex-tools` README to reflect the
expanded boundary

## Test Plan

- `cargo test -p codex-tools`
- `CARGO_TARGET_DIR=/tmp/codex-core-code-mode-adapters cargo test -p
codex-core --lib tools::spec::`
- `CARGO_TARGET_DIR=/tmp/codex-core-code-mode-adapters cargo test -p
codex-core --lib tools::code_mode::`
- `just bazel-lock-update`
- `just bazel-lock-check`
- `just argument-comment-lint`

## References

- #15923
- #15928
- #15944
- #15953
- #16031
- #16047
- #16129
2026-03-28 15:32:35 -07:00
Michael Bolin
c25c0d6e9e core: fix stale curated plugin cache refresh races (#16126)
## Why

The `plugin/list` force-sync path can race app-server startup's curated
plugin cache refresh.

Startup was capturing the configured curated plugin IDs from the initial
config snapshot. If `plugin/list` with `forceRemoteSync` removed curated
plugin entries from `config.toml` while that background refresh was
still in flight, the startup task could recreate cache directories for
plugins that had just been uninstalled.

That leaves the `plugin/list` response logically correct but the on-disk
cache stale, which matches the flaky Ubuntu arm failure seen in
`codex-app-server::all
suite::v2::plugin_list::plugin_list_force_remote_sync_reconciles_curated_plugin_state`
while validating [#16047](https://github.com/openai/codex/pull/16047).

## What

- change `codex-rs/core/src/plugins/manager.rs` so startup curated-repo
refresh rereads the current user `config.toml` before deciding which
curated plugin cache entries to refresh
- factor the configured-plugin parsing so the same logic can be reused
from either the config layer stack or the persisted user config value
- add a regression test that verifies curated plugin IDs are read from
the latest user config state before cache refresh runs

## Testing

- `cargo test -p codex-core
configured_curated_plugin_ids_from_codex_home_reads_latest_user_config
-- --nocapture`
- `cargo test -p codex-app-server
suite::v2::plugin_list::plugin_list_force_remote_sync_reconciles_curated_plugin_state
-- --nocapture`
- `just argument-comment-lint`
2026-03-28 15:00:39 -07:00
Michael Bolin
313fb95989 ci: keep rust-ci-full Windows argument-comment-lint on packaged wrapper (#16130)
## Why

PR #16106 switched `rust-ci-full` over to the native Bazel-backed
`argument-comment-lint` path on all three platforms.

That works on Linux and macOS, but the Windows leg in `rust-ci-full` now
fails before linting starts: Bazel dies while building `rules_rust`'s
`process_wrapper` tool, so `main` reports an `argument-comment-lint`
failure even though no Rust lint finding was produced.

Until native Windows Bazel linting is repaired, `rust-ci-full` should
keep the same Windows split that `rust-ci.yml` already uses.

## What changed

- restored the Windows-only nightly `argument-comment-lint` toolchain
setup in `rust-ci-full`
- limited the Bazel-backed lint step in `rust-ci-full` to non-Windows
runners
- routed the Windows runner back through
`tools/argument-comment-lint/run-prebuilt-linter.py`
- left the Linux and macOS `rust-ci-full` behavior unchanged

## Test plan

- loaded `.github/workflows/rust-ci-full.yml` and
`.github/workflows/rust-ci.yml` with `python3` + `yaml.safe_load(...)`
- inspected failing Actions run `23692864849`, especially job
`69023229311`, to confirm the Windows failure occurs in Bazel
`process_wrapper` setup before lint output is emitted

## References

- #16106
2026-03-28 14:50:19 -07:00
Michael Bolin
4e27a87ec6 codex-tools: extract configured tool specs (#16129)
## Why

This continues the `codex-tools` migration by moving another passive
tool-spec layer out of `codex-core`.

After `ToolSpec` moved into `codex-tools`, `codex-core` still owned
`ConfiguredToolSpec` and `create_tools_json_for_responses_api()`. Both
are data-model and serialization helpers rather than runtime
orchestration, so keeping them in `core/src/tools/registry.rs` and
`core/src/tools/spec.rs` left passive tool-definition code coupled to
`codex-core` longer than necessary.

## What changed

- moved `ConfiguredToolSpec` into `codex-rs/tools/src/tool_spec.rs`
- moved `create_tools_json_for_responses_api()` into
`codex-rs/tools/src/tool_spec.rs`
- re-exported the new surface from `codex-rs/tools/src/lib.rs`, which
remains exports-only
- updated `core/src/client.rs`, `core/src/tools/registry.rs`, and
`core/src/tools/router.rs` to consume the extracted types and serializer
from `codex-tools`
- moved the tool-list serialization test into
`codex-rs/tools/src/tool_spec_tests.rs`
- added focused unit coverage for `ConfiguredToolSpec::name()`
- simplified `core/src/tools/spec_tests.rs` to use the extracted
`ConfiguredToolSpec::name()` directly and removed the now-redundant
local `tool_name()` helper
- updated `codex-rs/tools/README.md` so the crate boundary reflects the
newly extracted tool-spec wrapper and serialization helper

## Test plan

- `cargo test -p codex-tools`
- `CARGO_TARGET_DIR=/tmp/codex-core-configured-spec cargo test -p
codex-core --lib tools::spec::`
- `CARGO_TARGET_DIR=/tmp/codex-core-configured-spec cargo test -p
codex-core --lib client::`
- `just fix -p codex-tools -p codex-core`
- `just argument-comment-lint`

## References

- #15923
- #15928
- #15944
- #15953
- #16031
- #16047
2026-03-28 14:24:14 -07:00
33 changed files with 3066 additions and 1966 deletions

View File

@@ -99,7 +99,21 @@ jobs:
run: |
sudo DEBIAN_FRONTEND=noninteractive apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
- name: Install nightly argument-comment-lint toolchain
if: ${{ runner.os == 'Windows' }}
shell: bash
run: |
rustup toolchain install nightly-2025-09-18 \
--profile minimal \
--component llvm-tools-preview \
--component rustc-dev \
--component rust-src \
--no-self-update
rustup default nightly-2025-09-18
- name: Run argument comment lint on codex-rs via Bazel
if: ${{ runner.os != 'Windows' }}
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
shell: bash
run: |
./.github/scripts/run-bazel-ci.sh \
@@ -110,6 +124,10 @@ jobs:
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
-- \
//codex-rs/...
- name: Run argument comment lint on codex-rs via packaged wrapper
if: ${{ runner.os == 'Windows' }}
shell: bash
run: python3 ./tools/argument-comment-lint/run-prebuilt-linter.py
# --- CI to validate on different os/targets --------------------------------
lint_build:

1
codex-rs/Cargo.lock generated
View File

@@ -2613,6 +2613,7 @@ dependencies = [
name = "codex-tools"
version = "0.0.0"
dependencies = [
"codex-code-mode",
"codex-protocol",
"pretty_assertions",
"rmcp",

View File

@@ -72,6 +72,7 @@ use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::W3cTraceContext;
use codex_tools::create_tools_json_for_responses_api;
use eventsource_stream::Event;
use eventsource_stream::EventStreamError;
use futures::StreamExt;
@@ -107,7 +108,6 @@ use crate::response_debug_context::extract_response_debug_context;
use crate::response_debug_context::extract_response_debug_context_from_api_error;
use crate::response_debug_context::telemetry_api_error_message;
use crate::response_debug_context::telemetry_transport_error_message;
use crate::tools::spec::create_tools_json_for_responses_api;
use crate::util::FeedbackRequestTags;
use crate::util::emit_feedback_auth_recovery_tags;
use crate::util::emit_feedback_request_tags_with_auth_env;

View File

@@ -29,6 +29,7 @@ use super::sync_openai_plugins_repo;
use crate::AuthManager;
use crate::SkillMetadata;
use crate::auth::CodexAuth;
use crate::config::CONFIG_TOML_FILE;
use crate::config::Config;
use crate::config::ConfigService;
use crate::config::ConfigServiceError;
@@ -1008,28 +1009,7 @@ impl PluginsManager {
auth_manager: Arc<AuthManager>,
) {
if config.features.enabled(Feature::Plugins) {
let mut configured_curated_plugin_ids =
configured_plugins_from_stack(&config.config_layer_stack)
.into_keys()
.filter_map(|plugin_key| match PluginId::parse(&plugin_key) {
Ok(plugin_id)
if plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME =>
{
Some(plugin_id)
}
Ok(_) => None,
Err(err) => {
warn!(
plugin_key,
error = %err,
"ignoring invalid configured plugin key during curated sync setup"
);
None
}
})
.collect::<Vec<_>>();
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
self.start_curated_repo_sync(configured_curated_plugin_ids);
self.start_curated_repo_sync();
start_startup_remote_plugin_sync_once(
Arc::clone(self),
self.codex_home.clone(),
@@ -1054,7 +1034,7 @@ impl PluginsManager {
}
}
fn start_curated_repo_sync(self: &Arc<Self>, configured_curated_plugin_ids: Vec<PluginId>) {
fn start_curated_repo_sync(self: &Arc<Self>) {
if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) {
return;
}
@@ -1065,6 +1045,8 @@ impl PluginsManager {
.spawn(
move || match sync_openai_plugins_repo(codex_home.as_path()) {
Ok(curated_plugin_version) => {
let configured_curated_plugin_ids =
configured_curated_plugin_ids_from_codex_home(codex_home.as_path());
match refresh_curated_plugin_cache(
codex_home.as_path(),
&curated_plugin_version,
@@ -1333,7 +1315,13 @@ fn configured_plugins_from_stack(
let Some(user_layer) = config_layer_stack.get_user_layer() else {
return HashMap::new();
};
let Some(plugins_value) = user_layer.config.get("plugins") else {
configured_plugins_from_user_config_value(&user_layer.config)
}
fn configured_plugins_from_user_config_value(
user_config: &toml::Value,
) -> HashMap<String, PluginConfig> {
let Some(plugins_value) = user_config.get("plugins") else {
return HashMap::new();
};
match plugins_value.clone().try_into() {
@@ -1345,6 +1333,60 @@ fn configured_plugins_from_stack(
}
}
fn configured_curated_plugin_ids(
configured_plugins: HashMap<String, PluginConfig>,
) -> Vec<PluginId> {
let mut configured_curated_plugin_ids = configured_plugins
.into_keys()
.filter_map(|plugin_key| match PluginId::parse(&plugin_key) {
Ok(plugin_id) if plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME => {
Some(plugin_id)
}
Ok(_) => None,
Err(err) => {
warn!(
plugin_key,
error = %err,
"ignoring invalid configured plugin key during curated sync setup"
);
None
}
})
.collect::<Vec<_>>();
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
configured_curated_plugin_ids
}
fn configured_curated_plugin_ids_from_codex_home(codex_home: &Path) -> Vec<PluginId> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let user_config = match fs::read_to_string(&config_path) {
Ok(user_config) => user_config,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(err) => {
warn!(
path = %config_path.display(),
error = %err,
"failed to read user config while refreshing curated plugin cache"
);
return Vec::new();
}
};
let user_config = match toml::from_str::<toml::Value>(&user_config) {
Ok(user_config) => user_config,
Err(err) => {
warn!(
path = %config_path.display(),
error = %err,
"failed to parse user config while refreshing curated plugin cache"
);
return Vec::new();
}
};
configured_curated_plugin_ids(configured_plugins_from_user_config_value(&user_config))
}
fn load_plugin(
config_name: String,
plugin: &PluginConfig,

View File

@@ -2207,6 +2207,43 @@ fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_curren
);
}
#[test]
fn configured_curated_plugin_ids_from_codex_home_reads_latest_user_config() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."slack@openai-curated"]
enabled = true
[plugins."sample@debug"]
enabled = true
"#,
);
assert_eq!(
configured_curated_plugin_ids_from_codex_home(tmp.path())
.into_iter()
.map(|plugin_id| plugin_id.as_key())
.collect::<Vec<_>>(),
vec!["slack@openai-curated".to_string()]
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
assert_eq!(
configured_curated_plugin_ids_from_codex_home(tmp.path()),
Vec::<PluginId>::new()
);
}
#[test]
fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
let tmp = tempfile::tempdir().unwrap();

View File

@@ -19,7 +19,6 @@ use crate::codex::Session;
use crate::codex::TurnContext;
use crate::function_tool::FunctionCallError;
use crate::tools::ToolRouter;
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::context::ToolPayload;
@@ -29,6 +28,7 @@ use crate::tools::router::ToolCallSource;
use crate::tools::router::ToolRouterParams;
use crate::unified_exec::resolve_max_tokens;
use codex_features::Feature;
use codex_tools::tool_spec_to_code_mode_tool_definition;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy;
use codex_utils_output_truncation::truncate_function_output_items_with_policy;
@@ -247,42 +247,13 @@ pub(super) async fn build_enabled_tools(
let mut out = router
.specs()
.into_iter()
.map(|spec| augment_tool_spec_for_code_mode(spec, /*code_mode_enabled*/ true))
.filter_map(enabled_tool_from_spec)
.filter_map(|spec| tool_spec_to_code_mode_tool_definition(&spec))
.collect::<Vec<_>>();
out.sort_by(|left, right| left.name.cmp(&right.name));
out.dedup_by(|left, right| left.name == right.name);
out
}
fn enabled_tool_from_spec(spec: ToolSpec) -> Option<codex_code_mode::ToolDefinition> {
let tool_name = spec.name().to_string();
if !codex_code_mode::is_code_mode_nested_tool(&tool_name) {
return None;
}
match spec {
ToolSpec::Function(tool) => Some(codex_code_mode::ToolDefinition {
name: tool_name,
description: tool.description,
kind: codex_code_mode::CodeModeToolKind::Function,
input_schema: serde_json::to_value(&tool.parameters).ok(),
output_schema: tool.output_schema,
}),
ToolSpec::Freeform(tool) => Some(codex_code_mode::ToolDefinition {
name: tool_name,
description: tool.description,
kind: codex_code_mode::CodeModeToolKind::Freeform,
input_schema: None,
output_schema: None,
}),
ToolSpec::LocalShell {}
| ToolSpec::ImageGeneration { .. }
| ToolSpec::ToolSearch { .. }
| ToolSpec::WebSearch { .. } => None,
}
}
async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
let nested_tools_config = exec.turn.tools_config.for_code_mode_nested_tools();
let mcp_tools = exec

View File

@@ -1,49 +0,0 @@
use crate::client_common::tools::ToolSpec;
#[allow(unused_imports)]
#[cfg(test)]
pub(crate) use codex_code_mode::append_code_mode_sample;
#[allow(unused_imports)]
#[cfg(test)]
pub(crate) use codex_code_mode::render_json_schema_to_typescript;
pub(crate) fn augment_tool_spec_for_code_mode(spec: ToolSpec, code_mode_enabled: bool) -> ToolSpec {
if !code_mode_enabled {
return spec;
}
match spec {
ToolSpec::Function(mut tool) => {
let input_type = serde_json::to_value(&tool.parameters)
.ok()
.map(|schema| codex_code_mode::render_json_schema_to_typescript(&schema))
.unwrap_or_else(|| "unknown".to_string());
let output_type = tool
.output_schema
.as_ref()
.map(codex_code_mode::render_json_schema_to_typescript)
.unwrap_or_else(|| "unknown".to_string());
tool.description = codex_code_mode::append_code_mode_sample(
&tool.description,
&tool.name,
"args",
input_type,
output_type,
);
ToolSpec::Function(tool)
}
ToolSpec::Freeform(mut tool) => {
if tool.name != codex_code_mode::PUBLIC_TOOL_NAME {
tool.description = codex_code_mode::append_code_mode_sample(
&tool.description,
&tool.name,
"input",
"string".to_string(),
"unknown".to_string(),
);
}
ToolSpec::Freeform(tool)
}
other => other,
}
}

View File

@@ -1,104 +0,0 @@
use super::append_code_mode_sample;
use super::render_json_schema_to_typescript;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn render_json_schema_to_typescript_renders_object_properties() {
let schema = json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"recursive": {"type": "boolean"}
},
"required": ["path"],
"additionalProperties": false
});
assert_eq!(
render_json_schema_to_typescript(&schema),
"{ path: string; recursive?: boolean; }"
);
}
#[test]
fn render_json_schema_to_typescript_renders_anyof_unions() {
let schema = json!({
"anyOf": [
{"const": "pending"},
{"const": "done"},
{"type": "number"}
]
});
assert_eq!(
render_json_schema_to_typescript(&schema),
"\"pending\" | \"done\" | number"
);
}
#[test]
fn render_json_schema_to_typescript_renders_additional_properties() {
let schema = json!({
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {"type": "string"}
}
},
"additionalProperties": {"type": "integer"}
});
assert_eq!(
render_json_schema_to_typescript(&schema),
"{ tags?: Array<string>; [key: string]: number; }"
);
}
#[test]
fn render_json_schema_to_typescript_sorts_object_properties() {
let schema = json!({
"type": "object",
"properties": {
"structuredContent": {"type": "string"},
"_meta": {"type": "string"},
"isError": {"type": "boolean"},
"content": {"type": "array", "items": {"type": "string"}}
},
"required": ["content"]
});
assert_eq!(
render_json_schema_to_typescript(&schema),
"{ _meta?: string; content: Array<string>; isError?: boolean; structuredContent?: string; }"
);
}
#[test]
fn append_code_mode_sample_uses_global_tools_for_valid_identifiers() {
assert_eq!(
append_code_mode_sample(
"desc",
"mcp__ologs__get_profile",
"args",
"{ foo: string }".to_string(),
"unknown".to_string(),
),
"desc\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__ologs__get_profile(args: { foo: string }): Promise<unknown>; };\n```"
);
}
#[test]
fn append_code_mode_sample_normalizes_invalid_identifiers() {
assert_eq!(
append_code_mode_sample(
"desc",
"mcp__rmcp__echo-tool",
"args",
"{ foo: string }".to_string(),
"unknown".to_string(),
),
"desc\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__rmcp__echo_tool(args: { foo: string }): Promise<unknown>; };\n```"
);
}

View File

@@ -1,5 +1,4 @@
pub mod code_mode;
pub(crate) mod code_mode_description;
pub mod context;
pub(crate) mod discoverable;
pub mod events;

View File

@@ -24,6 +24,7 @@ use codex_hooks::HookToolInput;
use codex_hooks::HookToolInputLocalShell;
use codex_hooks::HookToolKind;
use codex_protocol::models::ResponseInputItem;
use codex_tools::ConfiguredToolSpec;
use codex_utils_readiness::Readiness;
use serde_json::Value;
use tracing::warn;
@@ -436,21 +437,6 @@ impl ToolRegistry {
}
}
#[derive(Debug, Clone)]
pub struct ConfiguredToolSpec {
pub spec: ToolSpec,
pub supports_parallel_tool_calls: bool,
}
impl ConfiguredToolSpec {
pub fn new(spec: ToolSpec, supports_parallel_tool_calls: bool) -> Self {
Self {
spec,
supports_parallel_tool_calls,
}
}
}
pub struct ToolRegistryBuilder {
handlers: HashMap<String, Arc<dyn AnyToolHandler>>,
specs: Vec<ConfiguredToolSpec>,

View File

@@ -9,7 +9,6 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::discoverable::DiscoverableTool;
use crate::tools::registry::AnyToolResult;
use crate::tools::registry::ConfiguredToolSpec;
use crate::tools::registry::ToolRegistry;
use crate::tools::spec::ToolsConfig;
use crate::tools::spec::build_specs_with_discoverable_tools;
@@ -18,6 +17,7 @@ use codex_protocol::models::LocalShellAction;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::SearchToolCallParams;
use codex_protocol::models::ShellToolCallParams;
use codex_tools::ConfiguredToolSpec;
use rmcp::model::Tool;
use std::collections::HashMap;
use std::sync::Arc;
@@ -66,7 +66,7 @@ impl ToolRouter {
specs
.iter()
.filter_map(|configured_tool| {
if !codex_code_mode::is_code_mode_nested_tool(configured_tool.spec.name()) {
if !codex_code_mode::is_code_mode_nested_tool(configured_tool.name()) {
Some(configured_tool.spec.clone())
} else {
None
@@ -101,7 +101,7 @@ impl ToolRouter {
pub fn find_spec(&self, tool_name: &str) -> Option<ToolSpec> {
self.specs
.iter()
.find(|config| config.spec.name() == tool_name)
.find(|config| config.name() == tool_name)
.map(|config| config.spec.clone())
}
@@ -109,7 +109,7 @@ impl ToolRouter {
self.specs
.iter()
.filter(|config| config.supports_parallel_tool_calls)
.any(|config| config.spec.name() == tool_name)
.any(|config| config.name() == tool_name)
}
#[instrument(level = "trace", skip_all, err)]

File diff suppressed because it is too large Load Diff

View File

@@ -4,19 +4,39 @@ use crate::models_manager::model_info::with_config_overrides;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::tools::ToolRouter;
use crate::tools::registry::ConfiguredToolSpec;
use crate::tools::router::ToolRouterParams;
use codex_app_server_protocol::AppInfo;
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelsResponse;
use codex_tools::AdditionalProperties;
use codex_tools::CommandToolOptions;
use codex_tools::ConfiguredToolSpec;
use codex_tools::FreeformTool;
use codex_tools::ResponsesApiWebSearchFilters;
use codex_tools::ResponsesApiWebSearchUserLocation;
use codex_tools::SpawnAgentToolOptions;
use codex_tools::ViewImageToolOptions;
use codex_tools::WaitAgentTimeoutOptions;
use codex_tools::create_close_agent_tool_v1;
use codex_tools::create_close_agent_tool_v2;
use codex_tools::create_exec_command_tool;
use codex_tools::create_request_permissions_tool;
use codex_tools::create_request_user_input_tool;
use codex_tools::create_resume_agent_tool;
use codex_tools::create_send_input_tool_v1;
use codex_tools::create_send_message_tool;
use codex_tools::create_spawn_agent_tool_v1;
use codex_tools::create_spawn_agent_tool_v2;
use codex_tools::create_view_image_tool;
use codex_tools::create_wait_agent_tool_v1;
use codex_tools::create_wait_agent_tool_v2;
use codex_tools::create_write_stdin_tool;
use codex_tools::mcp_tool_to_deferred_responses_api_tool;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
use super::*;
@@ -54,10 +74,6 @@ fn discoverable_connector(id: &str, name: &str, description: &str) -> Discoverab
}))
}
fn windows_shell_safety_description() -> String {
format!("\n\n{}", super::windows_destructive_filesystem_guidance())
}
fn search_capable_model_info() -> ModelInfo {
let config = test_config();
let mut model_info =
@@ -107,16 +123,12 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() {
);
}
fn tool_name(tool: &ToolSpec) -> &str {
tool.name()
}
// Avoid order-based assertions; compare via set containment instead.
fn assert_contains_tool_names(tools: &[ConfiguredToolSpec], expected_subset: &[&str]) {
use std::collections::HashSet;
let mut names = HashSet::new();
let mut duplicates = Vec::new();
for name in tools.iter().map(|t| tool_name(&t.spec)) {
for name in tools.iter().map(ConfiguredToolSpec::name) {
if !names.insert(name) {
duplicates.push(name);
}
@@ -136,7 +148,7 @@ fn assert_contains_tool_names(tools: &[ConfiguredToolSpec], expected_subset: &[&
fn assert_lacks_tool_name(tools: &[ConfiguredToolSpec], expected_absent: &str) {
let names = tools
.iter()
.map(|tool| tool_name(&tool.spec))
.map(ConfiguredToolSpec::name)
.collect::<Vec<_>>();
assert!(
!names.contains(&expected_absent),
@@ -154,10 +166,31 @@ fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> {
}
}
fn request_user_input_tool_spec(default_mode_request_user_input: bool) -> ToolSpec {
create_request_user_input_tool(request_user_input_tool_description(
default_mode_request_user_input,
))
}
fn spawn_agent_tool_options(config: &ToolsConfig) -> SpawnAgentToolOptions<'_> {
SpawnAgentToolOptions {
available_models: &config.available_models,
agent_type_description: crate::agent::role::spawn_tool_spec::build(&config.agent_roles),
}
}
fn wait_agent_timeout_options() -> WaitAgentTimeoutOptions {
WaitAgentTimeoutOptions {
default_timeout_ms: DEFAULT_WAIT_TIMEOUT_MS,
min_timeout_ms: MIN_WAIT_TIMEOUT_MS,
max_timeout_ms: MAX_WAIT_TIMEOUT_MS,
}
}
fn find_tool<'a>(tools: &'a [ConfiguredToolSpec], expected_name: &str) -> &'a ConfiguredToolSpec {
tools
.iter()
.find(|tool| tool_name(&tool.spec) == expected_name)
.find(|tool| tool.name() == expected_name)
.unwrap_or_else(|| panic!("expected tool {expected_name}"))
}
@@ -289,7 +322,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
let mut actual: BTreeMap<String, ToolSpec> = BTreeMap::from([]);
let mut duplicate_names = Vec::new();
for t in &tools {
let name = tool_name(&t.spec).to_string();
let name = t.name().to_string();
if actual.insert(name.clone(), t.spec.clone()).is_some() {
duplicate_names.push(name);
}
@@ -302,12 +335,13 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
// Build expected from the same helpers used by the builder.
let mut expected: BTreeMap<String, ToolSpec> = BTreeMap::from([]);
for spec in [
create_exec_command_tool(
/*allow_login_shell*/ true, /*exec_permission_approvals_enabled*/ false,
),
create_exec_command_tool(CommandToolOptions {
allow_login_shell: true,
exec_permission_approvals_enabled: false,
}),
create_write_stdin_tool(),
PLAN_TOOL.clone(),
create_request_user_input_tool(CollaborationModesConfig::default()),
request_user_input_tool_spec(/*default_mode_request_user_input*/ false),
create_apply_patch_freeform_tool(),
ToolSpec::WebSearch {
external_web_access: Some(true),
@@ -316,36 +350,38 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
search_context_size: None,
search_content_types: None,
},
create_view_image_tool(config.can_request_original_image_detail),
create_view_image_tool(ViewImageToolOptions {
can_request_original_image_detail: config.can_request_original_image_detail,
}),
] {
expected.insert(tool_name(&spec).to_string(), spec);
expected.insert(spec.name().to_string(), spec);
}
let collab_specs = if config.multi_agent_v2 {
vec![
create_spawn_agent_tool_v2(&config),
create_spawn_agent_tool_v2(spawn_agent_tool_options(&config)),
create_send_message_tool(),
create_wait_agent_tool_v2(),
create_wait_agent_tool_v2(wait_agent_timeout_options()),
create_close_agent_tool_v2(),
]
} else {
vec![
create_spawn_agent_tool_v1(&config),
create_spawn_agent_tool_v1(spawn_agent_tool_options(&config)),
create_send_input_tool_v1(),
create_wait_agent_tool_v1(),
create_wait_agent_tool_v1(wait_agent_timeout_options()),
create_close_agent_tool_v1(),
]
};
for spec in collab_specs {
expected.insert(tool_name(&spec).to_string(), spec);
expected.insert(spec.name().to_string(), spec);
}
if !config.multi_agent_v2 {
let spec = create_resume_agent_tool();
expected.insert(tool_name(&spec).to_string(), spec);
expected.insert(spec.name().to_string(), spec);
}
if config.exec_permission_approvals_enabled {
let spec = create_request_permissions_tool();
expected.insert(tool_name(&spec).to_string(), spec);
let spec = create_request_permissions_tool(request_permissions_tool_description());
expected.insert(spec.name().to_string(), spec);
}
// Exact name set match — this is the only test allowed to fail when tools change.
@@ -732,7 +768,7 @@ fn request_user_input_description_reflects_default_mode_feature_flag() {
let request_user_input_tool = find_tool(&tools, "request_user_input");
assert_eq!(
request_user_input_tool.spec,
create_request_user_input_tool(CollaborationModesConfig::default())
request_user_input_tool_spec(/*default_mode_request_user_input*/ false)
);
features.enable(Feature::DefaultModeRequestUserInput);
@@ -756,9 +792,7 @@ fn request_user_input_description_reflects_default_mode_feature_flag() {
let request_user_input_tool = find_tool(&tools, "request_user_input");
assert_eq!(
request_user_input_tool.spec,
create_request_user_input_tool(CollaborationModesConfig {
default_mode_request_user_input: true,
})
request_user_input_tool_spec(/*default_mode_request_user_input*/ true)
);
}
@@ -808,7 +842,7 @@ fn request_permissions_requires_feature_flag() {
let request_permissions_tool = find_tool(&tools, "request_permissions");
assert_eq!(
request_permissions_tool.spec,
create_request_permissions_tool()
create_request_permissions_tool(request_permissions_tool_description())
);
}
@@ -1679,11 +1713,7 @@ fn test_test_model_info_includes_sync_tool() {
)
.build();
assert!(
tools
.iter()
.any(|tool| tool_name(&tool.spec) == "test_sync_tool")
);
assert!(tools.iter().any(|tool| tool.name() == "test_sync_tool"));
}
#[test]
@@ -1817,7 +1847,7 @@ fn test_build_specs_mcp_tools_sorted_by_name() {
// Only assert that the MCP tools themselves are sorted by fully-qualified name.
let mcp_names: Vec<_> = tools
.iter()
.map(|t| tool_name(&t.spec).to_string())
.map(|t| t.name().to_string())
.filter(|n| n.starts_with("test_server/"))
.collect();
let expected = vec![
@@ -2064,7 +2094,7 @@ fn tool_suggest_is_not_registered_without_feature_flag() {
assert!(
!tools
.iter()
.any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME)
.any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAME)
);
}
@@ -2155,7 +2185,7 @@ fn tool_suggest_requires_apps_and_plugins_features() {
assert!(
!tools
.iter()
.any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME),
.any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAME),
"tool_suggest should be absent when {disabled_feature:?} is disabled"
);
}
@@ -2648,177 +2678,6 @@ fn test_mcp_tool_anyof_defaults_to_string() {
);
}
#[test]
fn test_shell_tool() {
let tool = super::create_shell_tool(/*exec_permission_approvals_enabled*/ false);
let ToolSpec::Function(ResponsesApiTool {
description, name, ..
}) = &tool
else {
panic!("expected function tool");
};
assert_eq!(name, "shell");
let expected = 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()
};
assert_eq!(description, &expected);
}
#[test]
fn test_exec_command_tool_windows_description_includes_shell_safety_guidance() {
let tool = super::create_exec_command_tool(
/*allow_login_shell*/ true, /*exec_permission_approvals_enabled*/ false,
);
let ToolSpec::Function(ResponsesApiTool {
description, name, ..
}) = &tool
else {
panic!("expected function tool");
};
assert_eq!(name, "exec_command");
let expected = 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()
};
assert_eq!(description, &expected);
}
#[test]
fn shell_tool_with_request_permission_includes_additional_permissions() {
let tool = super::create_shell_tool(/*exec_permission_approvals_enabled*/ true);
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
panic!("expected function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("expected object parameters");
};
assert!(properties.contains_key("additional_permissions"));
let Some(JsonSchema::String {
description: Some(description),
}) = properties.get("sandbox_permissions")
else {
panic!("expected sandbox_permissions description");
};
assert!(description.contains("with_additional_permissions"));
assert!(description.contains("filesystem or network permissions"));
let Some(JsonSchema::Object {
properties: additional_properties,
..
}) = properties.get("additional_permissions")
else {
panic!("expected additional_permissions schema");
};
assert!(additional_properties.contains_key("network"));
assert!(additional_properties.contains_key("file_system"));
assert!(!additional_properties.contains_key("macos"));
}
#[test]
fn request_permissions_tool_includes_full_permission_schema() {
let tool = super::create_request_permissions_tool();
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
panic!("expected function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("expected object parameters");
};
let Some(JsonSchema::Object {
properties: permission_properties,
additional_properties,
..
}) = properties.get("permissions")
else {
panic!("expected permissions object");
};
assert_eq!(additional_properties, &Some(false.into()));
assert!(permission_properties.contains_key("network"));
assert!(permission_properties.contains_key("file_system"));
assert!(!permission_properties.contains_key("macos"));
let Some(JsonSchema::Object {
properties: network_properties,
additional_properties,
..
}) = permission_properties.get("network")
else {
panic!("expected network object");
};
assert_eq!(additional_properties, &Some(false.into()));
assert!(network_properties.contains_key("enabled"));
let Some(JsonSchema::Object {
properties: file_system_properties,
additional_properties,
..
}) = permission_properties.get("file_system")
else {
panic!("expected file_system object");
};
assert_eq!(additional_properties, &Some(false.into()));
assert!(file_system_properties.contains_key("read"));
assert!(file_system_properties.contains_key("write"));
}
#[test]
fn test_shell_command_tool() {
let tool = super::create_shell_command_tool(
/*allow_login_shell*/ true, /*exec_permission_approvals_enabled*/ false,
);
let ToolSpec::Function(ResponsesApiTool {
description, name, ..
}) = &tool
else {
panic!("expected function tool");
};
assert_eq!(name, "shell_command");
let expected = 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()
};
assert_eq!(description, &expected);
}
#[test]
fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
let config = test_config();
@@ -3106,38 +2965,3 @@ fn code_mode_exec_description_omits_nested_tool_details_when_not_code_mode_only(
assert!(!description.contains("### `update_plan` (`update_plan`)"));
assert!(!description.contains("### `view_image` (`view_image`)"));
}
#[test]
fn chat_tools_include_top_level_name() {
let properties =
BTreeMap::from([("foo".to_string(), JsonSchema::String { description: None })]);
let tools = vec![ToolSpec::Function(ResponsesApiTool {
name: "demo".to_string(),
description: "A demo tool".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: None,
},
output_schema: None,
})];
let responses_json = create_tools_json_for_responses_api(&tools).unwrap();
assert_eq!(
responses_json,
vec![json!({
"type": "function",
"name": "demo",
"description": "A demo tool",
"strict": false,
"parameters": {
"type": "object",
"properties": {
"foo": { "type": "string" }
},
},
})]
);
}

View File

@@ -1975,10 +1975,14 @@ mod tests {
let cwd = tempdir().expect("create temp cwd");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
approvals_reviewer: Some(ApprovalsReviewer::User),
..Default::default()
})
.fallback_cwd(Some(cwd.path().to_path_buf()))
.build()
.await
.expect("build default config");
.expect("build config with manual-only review policy");
let params = thread_start_params_from_config(&config);
@@ -1992,17 +1996,16 @@ mod tests {
async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() {
let codex_home = tempdir().expect("create temp codex home");
let cwd = tempdir().expect("create temp cwd");
std::fs::write(
codex_home.path().join("config.toml"),
"approvals_reviewer = \"guardian_subagent\"\n",
)
.expect("write auto-review config");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent),
..Default::default()
})
.fallback_cwd(Some(cwd.path().to_path_buf()))
.build()
.await
.expect("build auto-review config");
.expect("build config with guardian review policy");
let params = thread_start_params_from_config(&config);

View File

@@ -8,6 +8,7 @@ version.workspace = true
workspace = true
[dependencies]
codex-code-mode = { workspace = true }
codex-protocol = { workspace = true }
rmcp = { workspace = true, default-features = false, features = [
"base64",

View File

@@ -12,6 +12,7 @@ schema and Responses API tool primitives that no longer need to live in
- `AdditionalProperties`
- `ToolDefinition`
- `ToolSpec`
- `ConfiguredToolSpec`
- `ResponsesApiTool`
- `FreeformTool`
- `FreeformToolFormat`
@@ -20,14 +21,21 @@ schema and Responses API tool primitives that no longer need to live in
- `ResponsesApiWebSearchUserLocation`
- `ResponsesApiNamespace`
- `ResponsesApiNamespaceTool`
- code-mode `ToolSpec` adapters
- local host tool spec builders for shell/exec/request-permissions/view-image
- collaboration and agent-job `ToolSpec` builders for spawn/send/wait/close,
`request_user_input`, and CSV fanout/reporting
- `parse_tool_input_schema()`
- `parse_dynamic_tool()`
- `parse_mcp_tool()`
- `create_tools_json_for_responses_api()`
- `mcp_call_tool_result_output_schema()`
- `tool_definition_to_responses_api_tool()`
- `dynamic_tool_to_responses_api_tool()`
- `mcp_tool_to_responses_api_tool()`
- `mcp_tool_to_deferred_responses_api_tool()`
- `augment_tool_spec_for_code_mode()`
- `tool_spec_to_code_mode_tool_definition()`
That extraction is the first step in a longer migration. The goal is not to
move all of `core/src/tools` into this crate in one shot. Instead, the plan is

View File

@@ -0,0 +1,141 @@
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::ToolSpec;
use std::collections::BTreeMap;
pub fn create_spawn_agents_on_csv_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"csv_path".to_string(),
JsonSchema::String {
description: Some("Path to the CSV file containing input rows.".to_string()),
},
),
(
"instruction".to_string(),
JsonSchema::String {
description: Some(
"Instruction template to apply to each CSV row. Use {column_name} placeholders to inject values from the row."
.to_string(),
),
},
),
(
"id_column".to_string(),
JsonSchema::String {
description: Some("Optional column name to use as stable item id.".to_string()),
},
),
(
"output_csv_path".to_string(),
JsonSchema::String {
description: Some("Optional output CSV path for exported results.".to_string()),
},
),
(
"max_concurrency".to_string(),
JsonSchema::Number {
description: Some(
"Maximum concurrent workers for this job. Defaults to 16 and is capped by config."
.to_string(),
),
},
),
(
"max_workers".to_string(),
JsonSchema::Number {
description: Some(
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
),
},
),
(
"max_runtime_seconds".to_string(),
JsonSchema::Number {
description: Some(
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
.to_string(),
),
},
),
(
"output_schema".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "spawn_agents_on_csv".to_string(),
description: "Process a CSV by spawning one worker sub-agent per row. The instruction string is a template where `{column}` placeholders are replaced with row values. Each worker must call `report_agent_job_result` with a JSON object (matching `output_schema` when provided); missing reports are treated as failures. This call blocks until all rows finish and automatically exports results to `output_csv_path` (or a default path)."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["csv_path".to_string(), "instruction".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
pub fn create_report_agent_job_result_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"job_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the job.".to_string()),
},
),
(
"item_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the job item.".to_string()),
},
),
(
"result".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
),
(
"stop".to_string(),
JsonSchema::Boolean {
description: Some(
"Optional. When true, cancels the remaining job items after this result is recorded."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "report_agent_job_result".to_string(),
description:
"Worker-only tool to report a result for an agent job item. Main agents should not call this."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec![
"job_id".to_string(),
"item_id".to_string(),
"result".to_string(),
]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
#[cfg(test)]
#[path = "agent_job_tool_tests.rs"]
mod tests;

View File

@@ -0,0 +1,140 @@
use super::*;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn spawn_agents_on_csv_tool_requires_csv_and_instruction() {
assert_eq!(
create_spawn_agents_on_csv_tool(),
ToolSpec::Function(ResponsesApiTool {
name: "spawn_agents_on_csv".to_string(),
description: "Process a CSV by spawning one worker sub-agent per row. The instruction string is a template where `{column}` placeholders are replaced with row values. Each worker must call `report_agent_job_result` with a JSON object (matching `output_schema` when provided); missing reports are treated as failures. This call blocks until all rows finish and automatically exports results to `output_csv_path` (or a default path)."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
(
"csv_path".to_string(),
JsonSchema::String {
description: Some("Path to the CSV file containing input rows.".to_string()),
},
),
(
"instruction".to_string(),
JsonSchema::String {
description: Some(
"Instruction template to apply to each CSV row. Use {column_name} placeholders to inject values from the row."
.to_string(),
),
},
),
(
"id_column".to_string(),
JsonSchema::String {
description: Some("Optional column name to use as stable item id.".to_string()),
},
),
(
"output_csv_path".to_string(),
JsonSchema::String {
description: Some("Optional output CSV path for exported results.".to_string()),
},
),
(
"max_concurrency".to_string(),
JsonSchema::Number {
description: Some(
"Maximum concurrent workers for this job. Defaults to 16 and is capped by config."
.to_string(),
),
},
),
(
"max_workers".to_string(),
JsonSchema::Number {
description: Some(
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
),
},
),
(
"max_runtime_seconds".to_string(),
JsonSchema::Number {
description: Some(
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
.to_string(),
),
},
),
(
"output_schema".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
),
]),
required: Some(vec!["csv_path".to_string(), "instruction".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
);
}
#[test]
fn report_agent_job_result_tool_requires_result_payload() {
assert_eq!(
create_report_agent_job_result_tool(),
ToolSpec::Function(ResponsesApiTool {
name: "report_agent_job_result".to_string(),
description:
"Worker-only tool to report a result for an agent job item. Main agents should not call this."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
(
"job_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the job.".to_string()),
},
),
(
"item_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the job item.".to_string()),
},
),
(
"result".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
),
(
"stop".to_string(),
JsonSchema::Boolean {
description: Some(
"Optional. When true, cancels the remaining job items after this result is recorded."
.to_string(),
),
},
),
]),
required: Some(vec![
"job_id".to_string(),
"item_id".to_string(),
"result".to_string(),
]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
);
}

View File

@@ -0,0 +1,729 @@
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::ToolSpec;
use codex_protocol::openai_models::ModelPreset;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub struct SpawnAgentToolOptions<'a> {
pub available_models: &'a [ModelPreset],
pub agent_type_description: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WaitAgentTimeoutOptions {
pub default_timeout_ms: i64,
pub min_timeout_ms: i64,
pub max_timeout_ms: i64,
}
pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions<'_>) -> ToolSpec {
let available_models_description = spawn_agent_models_description(options.available_models);
let return_value_description =
"Returns the spawned agent id plus the user-facing nickname when available.";
let properties = spawn_agent_common_properties(&options.agent_type_description);
ToolSpec::Function(ResponsesApiTool {
name: "spawn_agent".to_string(),
description: spawn_agent_tool_description(
&available_models_description,
return_value_description,
),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
},
output_schema: Some(spawn_agent_output_schema_v1()),
})
}
pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions<'_>) -> ToolSpec {
let available_models_description = spawn_agent_models_description(options.available_models);
let return_value_description = "Returns the canonical task name for the spawned agent, plus the user-facing nickname when available.";
let mut properties = spawn_agent_common_properties(&options.agent_type_description);
properties.insert(
"task_name".to_string(),
JsonSchema::String {
description: Some(
"Task name for the new agent. Use lowercase letters, digits, and underscores."
.to_string(),
),
},
);
ToolSpec::Function(ResponsesApiTool {
name: "spawn_agent".to_string(),
description: spawn_agent_tool_description(
&available_models_description,
return_value_description,
),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["task_name".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(spawn_agent_output_schema_v2()),
})
}
pub fn create_send_input_tool_v1() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::String {
description: Some("Agent id to message (from spawn_agent).".to_string()),
},
),
(
"message".to_string(),
JsonSchema::String {
description: Some(
"Legacy plain-text message to send to the agent. Use either message or items."
.to_string(),
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"interrupt".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "send_input".to_string(),
description: "Send a message to an existing agent. Use interrupt=true to redirect work immediately. You should reuse the agent by send_input if you believe your assigned task is highly dependent on the context of a previous task."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
})
}
pub fn create_send_message_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::String {
description: Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"interrupt".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "send_message".to_string(),
description: "Add a message to an existing agent without triggering a new turn. Use interrupt=true to stop the current task first. In MultiAgentV2, this tool currently supports text content only."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string(), "items".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
})
}
pub fn create_assign_task_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::String {
description: Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"interrupt".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "assign_task".to_string(),
description: "Add a message to an existing agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. In MultiAgentV2, this tool currently supports text content only."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string(), "items".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
})
}
pub fn create_resume_agent_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"id".to_string(),
JsonSchema::String {
description: Some("Agent id to resume.".to_string()),
},
)]);
ToolSpec::Function(ResponsesApiTool {
name: "resume_agent".to_string(),
description:
"Resume a previously closed agent by id so it can receive send_input and wait_agent calls."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["id".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(resume_agent_output_schema()),
})
}
pub fn create_wait_agent_tool_v1(options: WaitAgentTimeoutOptions) -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "wait_agent".to_string(),
description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status."
.to_string(),
strict: false,
defer_loading: None,
parameters: wait_agent_tool_parameters_v1(options),
output_schema: Some(wait_output_schema_v1()),
})
}
pub fn create_wait_agent_tool_v2(options: WaitAgentTimeoutOptions) -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "wait_agent".to_string(),
description: "Wait for agents to reach a final status. Returns a brief wait summary instead of the agent's final content. Returns a timeout summary when no agent reaches a final status before the deadline."
.to_string(),
strict: false,
defer_loading: None,
parameters: wait_agent_tool_parameters_v2(options),
output_schema: Some(wait_output_schema_v2()),
})
}
pub fn create_list_agents_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"path_prefix".to_string(),
JsonSchema::String {
description: Some(
"Optional task-path prefix. Accepts the same relative or absolute task-path syntax as other MultiAgentV2 agent targets."
.to_string(),
),
},
)]);
ToolSpec::Function(ResponsesApiTool {
name: "list_agents".to_string(),
description:
"List live agents in the current root thread tree. Optionally filter by task-path prefix."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
},
output_schema: Some(list_agents_output_schema()),
})
}
pub fn create_close_agent_tool_v1() -> ToolSpec {
let properties = BTreeMap::from([(
"target".to_string(),
JsonSchema::String {
description: Some("Agent id to close (from spawn_agent).".to_string()),
},
)]);
ToolSpec::Function(ResponsesApiTool {
name: "close_agent".to_string(),
description: "Close an agent and any open descendants when they are no longer needed, and return the target agent's previous status before shutdown was requested. Don't keep agents open for too long if they are not needed anymore.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(close_agent_output_schema()),
})
}
pub fn create_close_agent_tool_v2() -> ToolSpec {
let properties = BTreeMap::from([(
"target".to_string(),
JsonSchema::String {
description: Some(
"Agent id or canonical task name to close (from spawn_agent).".to_string(),
),
},
)]);
ToolSpec::Function(ResponsesApiTool {
name: "close_agent".to_string(),
description: "Close an agent and any open descendants when they are no longer needed, and return the target agent's previous status before shutdown was requested. Don't keep agents open for too long if they are not needed anymore.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(close_agent_output_schema()),
})
}
fn agent_status_output_schema() -> Value {
json!({
"oneOf": [
{
"type": "string",
"enum": ["pending_init", "running", "shutdown", "not_found"]
},
{
"type": "object",
"properties": {
"completed": {
"type": ["string", "null"]
}
},
"required": ["completed"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"errored": {
"type": "string"
}
},
"required": ["errored"],
"additionalProperties": false
}
]
})
}
fn spawn_agent_output_schema_v1() -> Value {
json!({
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": "Thread identifier for the spawned agent."
},
"nickname": {
"type": ["string", "null"],
"description": "User-facing nickname for the spawned agent when available."
}
},
"required": ["agent_id", "nickname"],
"additionalProperties": false
})
}
fn spawn_agent_output_schema_v2() -> Value {
json!({
"type": "object",
"properties": {
"agent_id": {
"type": ["string", "null"],
"description": "Legacy thread identifier for the spawned agent."
},
"task_name": {
"type": "string",
"description": "Canonical task name for the spawned agent."
},
"nickname": {
"type": ["string", "null"],
"description": "User-facing nickname for the spawned agent when available."
}
},
"required": ["agent_id", "task_name", "nickname"],
"additionalProperties": false
})
}
fn send_input_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"submission_id": {
"type": "string",
"description": "Identifier for the queued input submission."
}
},
"required": ["submission_id"],
"additionalProperties": false
})
}
fn list_agents_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"agents": {
"type": "array",
"items": {
"type": "object",
"properties": {
"agent_name": {
"type": "string",
"description": "Canonical task name for the agent when available, otherwise the agent id."
},
"agent_status": {
"description": "Last known status of the agent.",
"allOf": [agent_status_output_schema()]
},
"last_task_message": {
"type": ["string", "null"],
"description": "Most recent user or inter-agent instruction received by the agent, when available."
}
},
"required": ["agent_name", "agent_status", "last_task_message"],
"additionalProperties": false
},
"description": "Live agents visible in the current root thread tree."
}
},
"required": ["agents"],
"additionalProperties": false
})
}
fn resume_agent_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"status": agent_status_output_schema()
},
"required": ["status"],
"additionalProperties": false
})
}
fn wait_output_schema_v1() -> Value {
json!({
"type": "object",
"properties": {
"status": {
"type": "object",
"description": "Final statuses keyed by agent id.",
"additionalProperties": agent_status_output_schema()
},
"timed_out": {
"type": "boolean",
"description": "Whether the wait call returned due to timeout before any agent reached a final status."
}
},
"required": ["status", "timed_out"],
"additionalProperties": false
})
}
fn wait_output_schema_v2() -> Value {
json!({
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Brief wait summary without the agent's final content."
},
"timed_out": {
"type": "boolean",
"description": "Whether the wait call returned due to timeout before any agent reached a final status."
}
},
"required": ["message", "timed_out"],
"additionalProperties": false
})
}
fn close_agent_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"previous_status": {
"description": "The agent status observed before shutdown was requested.",
"allOf": [agent_status_output_schema()]
}
},
"required": ["previous_status"],
"additionalProperties": false
})
}
fn create_collab_input_items_schema() -> JsonSchema {
let properties = BTreeMap::from([
(
"type".to_string(),
JsonSchema::String {
description: Some(
"Input item type: text, image, local_image, skill, or mention.".to_string(),
),
},
),
(
"text".to_string(),
JsonSchema::String {
description: Some("Text content when type is text.".to_string()),
},
),
(
"image_url".to_string(),
JsonSchema::String {
description: Some("Image URL when type is image.".to_string()),
},
),
(
"path".to_string(),
JsonSchema::String {
description: Some(
"Path when type is local_image/skill, or structured mention target such as app://<connector-id> or plugin://<plugin-name>@<marketplace-name> when type is mention."
.to_string(),
),
},
),
(
"name".to_string(),
JsonSchema::String {
description: Some("Display name when type is skill or mention.".to_string()),
},
),
]);
JsonSchema::Array {
items: Box::new(JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
}),
description: Some(
"Structured input items. Use this to pass explicit mentions (for example app:// connector paths)."
.to_string(),
),
}
}
fn spawn_agent_common_properties(agent_type_description: &str) -> BTreeMap<String, JsonSchema> {
BTreeMap::from([
(
"message".to_string(),
JsonSchema::String {
description: Some(
"Initial plain-text task for the new agent. Use either message or items."
.to_string(),
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"agent_type".to_string(),
JsonSchema::String {
description: Some(agent_type_description.to_string()),
},
),
(
"fork_context".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, fork the current thread history into the new agent before sending the initial prompt. This must be used when you want the new agent to have exactly the same context as you."
.to_string(),
),
},
),
(
"model".to_string(),
JsonSchema::String {
description: Some(
"Optional model override for the new agent. Replaces the inherited model."
.to_string(),
),
},
),
(
"reasoning_effort".to_string(),
JsonSchema::String {
description: Some(
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
.to_string(),
),
},
),
])
}
fn spawn_agent_tool_description(
available_models_description: &str,
return_value_description: &str,
) -> String {
format!(
r#"
Only use `spawn_agent` if and only if the user explicitly asks for sub-agents, delegation, or parallel agent work.
Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do not count as permission to spawn.
Agent-role guidance below only helps choose which agent to use after spawning is already authorized; it never authorizes spawning by itself.
Spawn a sub-agent for a well-scoped task. {return_value_description} This spawn_agent tool provides you access to smaller but more efficient sub-agents. A mini model can solve many tasks faster than the main model. You should follow the rules and guidelines below to use this tool.
{available_models_description}
### When to delegate vs. do the subtask yourself
- First, quickly analyze the overall user task and form a succinct high-level plan. Identify which tasks are immediate blockers on the critical path, and which tasks are sidecar tasks that are needed but can run in parallel without blocking the next local step. As part of that plan, explicitly decide what immediate task you should do locally right now. Do this planning step before delegating to agents so you do not hand off the immediate blocking task to a submodel and then waste time waiting on it.
- Use the smaller subagent when a subtask is easy enough for it to handle and can run in parallel with your local work. Prefer delegating concrete, bounded sidecar tasks that materially advance the main task without blocking your immediate next local step.
- Do not delegate urgent blocking work when your immediate next step depends on that result. If the very next action is blocked on that task, the main rollout should usually do it locally to keep the critical path moving.
- Keep work local when the subtask is too difficult to delegate well and when it is tightly coupled, urgent, or likely to block your immediate next step.
### Designing delegated subtasks
- Subtasks must be concrete, well-defined, and self-contained.
- Delegated subtasks must materially advance the main task.
- Do not duplicate work between the main rollout and delegated subtasks.
- Avoid issuing multiple delegate calls on the same unresolved thread unless the new delegated task is genuinely different and necessary.
- Narrow the delegated ask to the concrete output you need next.
- For coding tasks, prefer delegating concrete code-change worker subtasks over read-only explorer analysis when the subagent can make a bounded patch in a clear write scope.
- When delegating coding work, instruct the submodel to edit files directly in its forked workspace and list the file paths it changed in the final answer.
- For code-edit subtasks, decompose work so each delegated task has a disjoint write set.
### After you delegate
- Call wait_agent very sparingly. Only call wait_agent when you need the result immediately for the next critical-path step and you are blocked until it returns.
- Do not redo delegated subagent tasks yourself; focus on integrating results or tackling non-overlapping work.
- While the subagent is running in the background, do meaningful non-overlapping work immediately.
- Do not repeatedly wait by reflex.
- When a delegated coding task returns, quickly review the uploaded changes, then integrate or refine them.
### Parallel delegation patterns
- Run multiple independent information-seeking subtasks in parallel when you have distinct questions that can be answered independently.
- Split implementation into disjoint codebase slices and spawn multiple agents for them in parallel when the write scopes do not overlap.
- Delegate verification only when it can run in parallel with ongoing implementation and is likely to catch a concrete risk before final integration.
- The key is to find opportunities to spawn multiple independent subtasks in parallel within the same round, while ensuring each subtask is well-defined, self-contained, and materially advances the main task."#
)
}
fn spawn_agent_models_description(models: &[ModelPreset]) -> String {
let visible_models: Vec<&ModelPreset> =
models.iter().filter(|model| model.show_in_picker).collect();
if visible_models.is_empty() {
return "No picker-visible models are currently loaded.".to_string();
}
visible_models
.into_iter()
.map(|model| {
let efforts = model
.supported_reasoning_efforts
.iter()
.map(|preset| format!("{} ({})", preset.effort, preset.description))
.collect::<Vec<_>>()
.join(", ");
format!(
"- {} (`{}`): {} Default reasoning effort: {}. Supported reasoning efforts: {}.",
model.display_name,
model.model,
model.description,
model.default_reasoning_effort,
efforts
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn wait_agent_tool_parameters_v1(options: WaitAgentTimeoutOptions) -> JsonSchema {
let properties = BTreeMap::from([
(
"targets".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Agent ids to wait on. Pass multiple ids to wait for whichever finishes first."
.to_string(),
),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some(format!(
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
)),
},
),
]);
JsonSchema::Object {
properties,
required: Some(vec!["targets".to_string()]),
additional_properties: Some(false.into()),
}
}
fn wait_agent_tool_parameters_v2(options: WaitAgentTimeoutOptions) -> JsonSchema {
let properties = BTreeMap::from([
(
"targets".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
"Agent ids or canonical task names to wait on. Pass multiple targets to wait for whichever finishes first."
.to_string(),
),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some(format!(
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
)),
},
),
]);
JsonSchema::Object {
properties,
required: Some(vec!["targets".to_string()]),
additional_properties: Some(false.into()),
}
}
#[cfg(test)]
#[path = "agent_tool_tests.rs"]
mod tests;

View File

@@ -0,0 +1,152 @@
use super::*;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use pretty_assertions::assert_eq;
use serde_json::json;
fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset {
ModelPreset {
id: id.to_string(),
model: format!("{id}-model"),
display_name: format!("{id} display"),
description: format!("{id} description"),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: "Balanced".to_string(),
}],
supports_personality: false,
is_default: false,
upgrade: None,
show_in_picker,
availability_nux: None,
supported_in_api: true,
input_modalities: Vec::new(),
}
}
#[test]
fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
available_models: &[
model_preset("visible", /*show_in_picker*/ true),
model_preset("hidden", /*show_in_picker*/ false),
],
agent_type_description: "role help".to_string(),
});
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
output_schema,
..
}) = tool
else {
panic!("spawn_agent should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("spawn_agent should use object params");
};
assert!(description.contains("visible display (`visible-model`)"));
assert!(!description.contains("hidden display (`hidden-model`)"));
assert!(properties.contains_key("task_name"));
assert_eq!(
properties.get("agent_type"),
Some(&JsonSchema::String {
description: Some("role help".to_string()),
})
);
assert_eq!(required, Some(vec!["task_name".to_string()]));
assert_eq!(
output_schema.expect("spawn_agent output schema")["required"],
json!(["agent_id", "task_name", "nickname"])
);
}
#[test]
fn send_message_tool_requires_items_and_uses_submission_output() {
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
..
}) = create_send_message_tool()
else {
panic!("send_message should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("send_message should use object params");
};
assert!(properties.contains_key("target"));
assert!(properties.contains_key("items"));
assert!(!properties.contains_key("message"));
assert_eq!(
required,
Some(vec!["target".to_string(), "items".to_string()])
);
assert_eq!(
output_schema.expect("send_message output schema")["required"],
json!(["submission_id"])
);
}
#[test]
fn wait_agent_tool_v2_uses_task_targets_and_summary_output() {
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
..
}) = create_wait_agent_tool_v2(WaitAgentTimeoutOptions {
default_timeout_ms: 30_000,
min_timeout_ms: 10_000,
max_timeout_ms: 3_600_000,
})
else {
panic!("wait_agent should be a function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("wait_agent should use object params");
};
let Some(JsonSchema::Array {
description: Some(description),
..
}) = properties.get("targets")
else {
panic!("wait_agent should define targets array");
};
assert!(description.contains("canonical task names"));
assert_eq!(
output_schema.expect("wait output schema")["properties"]["message"]["description"],
json!("Brief wait summary without the agent's final content.")
);
}
#[test]
fn list_agents_tool_includes_path_prefix_and_agent_fields() {
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
..
}) = create_list_agents_tool()
else {
panic!("list_agents should be a function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("list_agents should use object params");
};
assert!(properties.contains_key("path_prefix"));
assert_eq!(
output_schema.expect("list_agents output schema")["properties"]["agents"]["items"]["required"],
json!(["agent_name", "agent_status", "last_task_message"])
);
}

View File

@@ -0,0 +1,60 @@
use crate::ToolSpec;
use codex_code_mode::CodeModeToolKind;
use codex_code_mode::ToolDefinition as CodeModeToolDefinition;
/// Augment tool descriptions with code-mode-specific exec samples.
pub fn augment_tool_spec_for_code_mode(spec: ToolSpec) -> ToolSpec {
let Some(description) = code_mode_tool_definition_for_spec(&spec)
.map(codex_code_mode::augment_tool_definition)
.map(|definition| definition.description)
else {
return spec;
};
match spec {
ToolSpec::Function(mut tool) => {
tool.description = description;
ToolSpec::Function(tool)
}
ToolSpec::Freeform(mut tool) => {
tool.description = description;
ToolSpec::Freeform(tool)
}
other => other,
}
}
/// Convert a supported nested tool spec into the code-mode runtime shape,
/// including the code-mode-specific description sample.
pub fn tool_spec_to_code_mode_tool_definition(spec: &ToolSpec) -> Option<CodeModeToolDefinition> {
let definition = code_mode_tool_definition_for_spec(spec)?;
codex_code_mode::is_code_mode_nested_tool(&definition.name)
.then(|| codex_code_mode::augment_tool_definition(definition))
}
fn code_mode_tool_definition_for_spec(spec: &ToolSpec) -> Option<CodeModeToolDefinition> {
match spec {
ToolSpec::Function(tool) => Some(CodeModeToolDefinition {
name: tool.name.clone(),
description: tool.description.clone(),
kind: CodeModeToolKind::Function,
input_schema: serde_json::to_value(&tool.parameters).ok(),
output_schema: tool.output_schema.clone(),
}),
ToolSpec::Freeform(tool) => Some(CodeModeToolDefinition {
name: tool.name.clone(),
description: tool.description.clone(),
kind: CodeModeToolKind::Freeform,
input_schema: None,
output_schema: None,
}),
ToolSpec::LocalShell {}
| ToolSpec::ImageGeneration { .. }
| ToolSpec::ToolSearch { .. }
| ToolSpec::WebSearch { .. } => None,
}
}
#[cfg(test)]
#[path = "code_mode_tests.rs"]
mod tests;

View File

@@ -0,0 +1,123 @@
use super::augment_tool_spec_for_code_mode;
use super::tool_spec_to_code_mode_tool_definition;
use crate::AdditionalProperties;
use crate::FreeformTool;
use crate::FreeformToolFormat;
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::ToolSpec;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn augment_tool_spec_for_code_mode_augments_function_tools() {
assert_eq!(
augment_tool_spec_for_code_mode(ToolSpec::Function(ResponsesApiTool {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
strict: false,
defer_loading: Some(true),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"order_id".to_string(),
JsonSchema::String { description: None },
)]),
required: Some(vec!["order_id".to_string()]),
additional_properties: Some(AdditionalProperties::Boolean(false)),
},
output_schema: Some(json!({
"type": "object",
"properties": {
"ok": {"type": "boolean"}
},
"required": ["ok"],
})),
})),
ToolSpec::Function(ResponsesApiTool {
name: "lookup_order".to_string(),
description: "Look up an order\n\nexec tool declaration:\n```ts\ndeclare const tools: { lookup_order(args: { order_id: string; }): Promise<{ ok: boolean; }>; };\n```".to_string(),
strict: false,
defer_loading: Some(true),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"order_id".to_string(),
JsonSchema::String { description: None },
)]),
required: Some(vec!["order_id".to_string()]),
additional_properties: Some(AdditionalProperties::Boolean(false)),
},
output_schema: Some(json!({
"type": "object",
"properties": {
"ok": {"type": "boolean"}
},
"required": ["ok"],
})),
})
);
}
#[test]
fn augment_tool_spec_for_code_mode_preserves_exec_tool_description() {
assert_eq!(
augment_tool_spec_for_code_mode(ToolSpec::Freeform(FreeformTool {
name: codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
description: "Run code".to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: "start: \"exec\"".to_string(),
},
})),
ToolSpec::Freeform(FreeformTool {
name: codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
description: "Run code".to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: "start: \"exec\"".to_string(),
},
})
);
}
#[test]
fn tool_spec_to_code_mode_tool_definition_returns_augmented_nested_tools() {
let spec = ToolSpec::Freeform(FreeformTool {
name: "apply_patch".to_string(),
description: "Apply a patch".to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: "start: \"patch\"".to_string(),
},
});
assert_eq!(
tool_spec_to_code_mode_tool_definition(&spec),
Some(codex_code_mode::ToolDefinition {
name: "apply_patch".to_string(),
description: "Apply a patch\n\nexec tool declaration:\n```ts\ndeclare const tools: { apply_patch(input: string): Promise<unknown>; };\n```".to_string(),
kind: codex_code_mode::CodeModeToolKind::Freeform,
input_schema: None,
output_schema: None,
})
);
}
#[test]
fn tool_spec_to_code_mode_tool_definition_skips_unsupported_variants() {
assert_eq!(
tool_spec_to_code_mode_tool_definition(&ToolSpec::ToolSearch {
execution: "sync".to_string(),
description: "Search".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
}),
None
);
}

View File

@@ -1,19 +1,50 @@
//! Shared tool definitions and Responses API tool primitives that can live
//! outside `codex-core`.
mod agent_job_tool;
mod agent_tool;
mod code_mode;
mod dynamic_tool;
mod json_schema;
mod local_tool;
mod mcp_tool;
mod request_user_input_tool;
mod responses_api;
mod tool_definition;
mod tool_spec;
mod view_image;
pub use agent_job_tool::create_report_agent_job_result_tool;
pub use agent_job_tool::create_spawn_agents_on_csv_tool;
pub use agent_tool::SpawnAgentToolOptions;
pub use agent_tool::WaitAgentTimeoutOptions;
pub use agent_tool::create_assign_task_tool;
pub use agent_tool::create_close_agent_tool_v1;
pub use agent_tool::create_close_agent_tool_v2;
pub use agent_tool::create_list_agents_tool;
pub use agent_tool::create_resume_agent_tool;
pub use agent_tool::create_send_input_tool_v1;
pub use agent_tool::create_send_message_tool;
pub use agent_tool::create_spawn_agent_tool_v1;
pub use agent_tool::create_spawn_agent_tool_v2;
pub use agent_tool::create_wait_agent_tool_v1;
pub use agent_tool::create_wait_agent_tool_v2;
pub use code_mode::augment_tool_spec_for_code_mode;
pub use code_mode::tool_spec_to_code_mode_tool_definition;
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 request_user_input_tool::create_request_user_input_tool;
pub use responses_api::FreeformTool;
pub use responses_api::FreeformToolFormat;
pub use responses_api::ResponsesApiNamespace;
@@ -25,6 +56,10 @@ pub use responses_api::mcp_tool_to_deferred_responses_api_tool;
pub use responses_api::mcp_tool_to_responses_api_tool;
pub use responses_api::tool_definition_to_responses_api_tool;
pub use tool_definition::ToolDefinition;
pub use tool_spec::ConfiguredToolSpec;
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,94 @@
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::ToolSpec;
use std::collections::BTreeMap;
pub fn create_request_user_input_tool(description: String) -> ToolSpec {
let option_props = BTreeMap::from([
(
"label".to_string(),
JsonSchema::String {
description: Some("User-facing label (1-5 words).".to_string()),
},
),
(
"description".to_string(),
JsonSchema::String {
description: Some(
"One short sentence explaining impact/tradeoff if selected.".to_string(),
),
},
),
]);
let options_schema = JsonSchema::Array {
description: Some(
"Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically."
.to_string(),
),
items: Box::new(JsonSchema::Object {
properties: option_props,
required: Some(vec!["label".to_string(), "description".to_string()]),
additional_properties: Some(false.into()),
}),
};
let question_props = BTreeMap::from([
(
"id".to_string(),
JsonSchema::String {
description: Some(
"Stable identifier for mapping answers (snake_case).".to_string(),
),
},
),
(
"header".to_string(),
JsonSchema::String {
description: Some(
"Short header label shown in the UI (12 or fewer chars).".to_string(),
),
},
),
(
"question".to_string(),
JsonSchema::String {
description: Some("Single-sentence prompt shown to the user.".to_string()),
},
),
("options".to_string(), options_schema),
]);
let questions_schema = JsonSchema::Array {
description: Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()),
items: Box::new(JsonSchema::Object {
properties: question_props,
required: Some(vec![
"id".to_string(),
"header".to_string(),
"question".to_string(),
"options".to_string(),
]),
additional_properties: Some(false.into()),
}),
};
let properties = BTreeMap::from([("questions".to_string(), questions_schema)]);
ToolSpec::Function(ResponsesApiTool {
name: "request_user_input".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["questions".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
#[cfg(test)]
#[path = "request_user_input_tool_tests.rs"]
mod tests;

View File

@@ -0,0 +1,102 @@
use super::*;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn request_user_input_tool_includes_questions_schema() {
assert_eq!(
create_request_user_input_tool("Ask the user to choose.".to_string()),
ToolSpec::Function(ResponsesApiTool {
name: "request_user_input".to_string(),
description: "Ask the user to choose.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"questions".to_string(),
JsonSchema::Array {
description: Some(
"Questions to show the user. Prefer 1 and do not exceed 3".to_string(),
),
items: Box::new(JsonSchema::Object {
properties: BTreeMap::from([
(
"header".to_string(),
JsonSchema::String {
description: Some(
"Short header label shown in the UI (12 or fewer chars)."
.to_string(),
),
},
),
(
"id".to_string(),
JsonSchema::String {
description: Some(
"Stable identifier for mapping answers (snake_case)."
.to_string(),
),
},
),
(
"options".to_string(),
JsonSchema::Array {
description: Some(
"Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically."
.to_string(),
),
items: Box::new(JsonSchema::Object {
properties: BTreeMap::from([
(
"description".to_string(),
JsonSchema::String {
description: Some(
"One short sentence explaining impact/tradeoff if selected."
.to_string(),
),
},
),
(
"label".to_string(),
JsonSchema::String {
description: Some(
"User-facing label (1-5 words)."
.to_string(),
),
},
),
]),
required: Some(vec![
"label".to_string(),
"description".to_string(),
]),
additional_properties: Some(false.into()),
}),
},
),
(
"question".to_string(),
JsonSchema::String {
description: Some(
"Single-sentence prompt shown to the user.".to_string(),
),
},
),
]),
required: Some(vec![
"id".to_string(),
"header".to_string(),
"question".to_string(),
"options".to_string(),
]),
additional_properties: Some(false.into()),
}),
},
)]),
required: Some(vec!["questions".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
);
}

View File

@@ -6,6 +6,7 @@ use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
use codex_protocol::config_types::WebSearchUserLocationType;
use serde::Serialize;
use serde_json::Value;
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
/// Responses API.
@@ -60,6 +61,41 @@ impl ToolSpec {
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ConfiguredToolSpec {
pub spec: ToolSpec,
pub supports_parallel_tool_calls: bool,
}
impl ConfiguredToolSpec {
pub fn new(spec: ToolSpec, supports_parallel_tool_calls: bool) -> Self {
Self {
spec,
supports_parallel_tool_calls,
}
}
pub fn name(&self) -> &str {
self.spec.name()
}
}
/// Returns JSON values that are compatible with Function Calling in the
/// Responses API:
/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
pub fn create_tools_json_for_responses_api(
tools: &[ToolSpec],
) -> Result<Vec<Value>, serde_json::Error> {
let mut tools_json = Vec::new();
for tool in tools {
let json = serde_json::to_value(tool)?;
tools_json.push(json);
}
Ok(tools_json)
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ResponsesApiWebSearchFilters {
#[serde(skip_serializing_if = "Option::is_none")]

View File

@@ -1,3 +1,4 @@
use super::ConfiguredToolSpec;
use super::ResponsesApiWebSearchFilters;
use super::ResponsesApiWebSearchUserLocation;
use super::ToolSpec;
@@ -6,6 +7,7 @@ use crate::FreeformTool;
use crate::FreeformToolFormat;
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::create_tools_json_for_responses_api;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
@@ -79,6 +81,29 @@ fn tool_spec_name_covers_all_variants() {
);
}
#[test]
fn configured_tool_spec_name_delegates_to_tool_spec() {
assert_eq!(
ConfiguredToolSpec::new(
ToolSpec::Function(ResponsesApiTool {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
output_schema: None,
}),
/*supports_parallel_tool_calls*/ true,
)
.name(),
"lookup_order"
);
}
#[test]
fn web_search_config_converts_to_responses_api_types() {
assert_eq!(
@@ -107,6 +132,40 @@ fn web_search_config_converts_to_responses_api_types() {
);
}
#[test]
fn create_tools_json_for_responses_api_includes_top_level_name() {
assert_eq!(
create_tools_json_for_responses_api(&[ToolSpec::Function(ResponsesApiTool {
name: "demo".to_string(),
description: "A demo tool".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"foo".to_string(),
JsonSchema::String { description: None },
)]),
required: None,
additional_properties: None,
},
output_schema: None,
})])
.expect("serialize tools"),
vec![json!({
"type": "function",
"name": "demo",
"description": "A demo tool",
"strict": false,
"parameters": {
"type": "object",
"properties": {
"foo": { "type": "string" }
},
},
})]
);
}
#[test]
fn web_search_tool_spec_serializes_expected_wire_shape() {
assert_eq!(

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()),
})
);
}

View File

@@ -180,7 +180,6 @@ impl SkillPopup {
})
});
out.truncate(MAX_POPUP_ROWS);
out
}
}
@@ -235,6 +234,8 @@ fn skill_popup_hint_line() -> Line<'static> {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
fn mention_item(index: usize) -> MentionItem {
MentionItem {
@@ -249,7 +250,7 @@ mod tests {
}
#[test]
fn filtered_mentions_are_capped_to_max_popup_rows() {
fn filtered_mentions_preserve_results_beyond_popup_height() {
let popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect());
let filtered_names: Vec<String> = popup
@@ -260,7 +261,7 @@ mod tests {
assert_eq!(
filtered_names,
(0..MAX_POPUP_ROWS)
(0..(MAX_POPUP_ROWS + 2))
.map(|idx| format!("Mention {idx:02}"))
.collect::<Vec<_>>()
);
@@ -269,4 +270,22 @@ mod tests {
(MAX_POPUP_ROWS as u16) + 2
);
}
fn render_popup(popup: &SkillPopup, width: u16) -> String {
let area = Rect::new(0, 0, width, popup.calculate_required_height(width));
let mut buf = Buffer::empty(area);
popup.render_ref(area, &mut buf);
format!("{buf:?}")
}
#[test]
fn scrolling_mentions_shifts_rendered_window_snapshot() {
let mut popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect());
for _ in 0..=MAX_POPUP_ROWS {
popup.move_down();
}
insta::assert_snapshot!("skill_popup_scrolled", render_popup(&popup, /*width*/ 72));
}
}

View File

@@ -0,0 +1,43 @@
---
source: tui/src/bottom_pane/skill_popup.rs
assertion_line: 289
expression: "render_popup(&popup, /*width*/ 72)"
---
Buffer {
area: Rect { x: 0, y: 0, width: 72, height: 10 },
content: [
" Mention 01 [Skill] Description 01 ",
" Mention 02 [Skill] Description 02 ",
" Mention 03 [Skill] Description 03 ",
" Mention 04 [Skill] Description 04 ",
" Mention 05 [Skill] Description 05 ",
" Mention 06 [Skill] Description 06 ",
" Mention 07 [Skill] Description 07 ",
" Mention 08 [Skill] Description 08 ",
" ",
" Press enter to insert or esc to close ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 7, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 36, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 8, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 13, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 27, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 30, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}