mirror of
https://github.com/openai/codex.git
synced 2026-03-29 03:26:32 +03:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09cc0984e6 | ||
|
|
4e119a3b38 | ||
|
|
46b653e73c | ||
|
|
f7ef9599ed | ||
|
|
a16a9109d7 | ||
|
|
2238c16a91 | ||
|
|
c25c0d6e9e | ||
|
|
313fb95989 | ||
|
|
4e27a87ec6 |
18
.github/workflows/rust-ci-full.yml
vendored
18
.github/workflows/rust-ci-full.yml
vendored
@@ -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
1
codex-rs/Cargo.lock
generated
@@ -2613,6 +2613,7 @@ dependencies = [
|
||||
name = "codex-tools"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-code-mode",
|
||||
"codex-protocol",
|
||||
"pretty_assertions",
|
||||
"rmcp",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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```"
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod code_mode;
|
||||
pub(crate) mod code_mode_description;
|
||||
pub mod context;
|
||||
pub(crate) mod discoverable;
|
||||
pub mod events;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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" }
|
||||
},
|
||||
},
|
||||
})]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
141
codex-rs/tools/src/agent_job_tool.rs
Normal file
141
codex-rs/tools/src/agent_job_tool.rs
Normal 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;
|
||||
140
codex-rs/tools/src/agent_job_tool_tests.rs
Normal file
140
codex-rs/tools/src/agent_job_tool_tests.rs
Normal 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,
|
||||
})
|
||||
);
|
||||
}
|
||||
729
codex-rs/tools/src/agent_tool.rs
Normal file
729
codex-rs/tools/src/agent_tool.rs
Normal 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;
|
||||
152
codex-rs/tools/src/agent_tool_tests.rs
Normal file
152
codex-rs/tools/src/agent_tool_tests.rs
Normal 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"])
|
||||
);
|
||||
}
|
||||
60
codex-rs/tools/src/code_mode.rs
Normal file
60
codex-rs/tools/src/code_mode.rs
Normal 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;
|
||||
123
codex-rs/tools/src/code_mode_tests.rs
Normal file
123
codex-rs/tools/src/code_mode_tests.rs
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
463
codex-rs/tools/src/local_tool.rs
Normal file
463
codex-rs/tools/src/local_tool.rs
Normal 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;
|
||||
442
codex-rs/tools/src/local_tool_tests.rs
Normal file
442
codex-rs/tools/src/local_tool_tests.rs
Normal 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,
|
||||
})
|
||||
);
|
||||
}
|
||||
94
codex-rs/tools/src/request_user_input_tool.rs
Normal file
94
codex-rs/tools/src/request_user_input_tool.rs
Normal 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;
|
||||
102
codex-rs/tools/src/request_user_input_tool_tests.rs
Normal file
102
codex-rs/tools/src/request_user_input_tool_tests.rs
Normal 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,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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!(
|
||||
|
||||
67
codex-rs/tools/src/view_image.rs
Normal file
67
codex-rs/tools/src/view_image.rs
Normal 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;
|
||||
67
codex-rs/tools/src/view_image_tests.rs
Normal file
67
codex-rs/tools/src/view_image_tests.rs
Normal 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()),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user