[codex] Move tool specs into core handlers (#21416)

## Why

This is the first mechanical slice of moving tool spec ownership toward
the handlers. `codex-tools` should keep shared primitives and conversion
helpers, while builtin tool specs and registration planning live in
`codex-core` with the handlers that own those tools.

Keeping this PR to relocation and import updates isolates the copy/move
review from the later logic change that wires specs through registered
handlers.

## What changed

- Moved builtin tool spec constructors from `codex-rs/tools/src` into
`codex-rs/core/src/tools/handlers/*_spec.rs` or nearby core tool
modules.
- Moved the registry planning code into
`codex-rs/core/src/tools/spec_plan.rs` and its associated types/tests
into core.
- Kept shared primitives in `codex-tools`, including `ToolSpec`,
schema/types, discovery/config primitives, dynamic/MCP conversion
helpers, and code-mode collection helpers.
- Updated handlers that referenced moved argument types or tool-name
constants to use the core spec modules.
- Moved spec tests next to the moved spec modules.

## Verification

- `cargo check -p codex-tools`
- `cargo check -p codex-core`
- `cargo test -p codex-tools`
- `cargo test -p codex-core _spec::tests`
- `cargo test -p codex-core tools::spec_plan::tests`
- `just fix -p codex-tools`
- `just fix -p codex-core`

Note: I also tried the broader `cargo test -p codex-core tools::`; it
reached the moved spec-plan/spec tests successfully, then aborted with a
stack overflow in
`tools::handlers::multi_agents::tests::tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtrees_closed`,
which is outside this spec relocation.
This commit is contained in:
pakrym-oai
2026-05-06 15:40:50 -07:00
committed by GitHub
Unverified
parent d5eea229cc
commit 9417cf9696
46 changed files with 858 additions and 801 deletions
+2 -1
View File
@@ -10,6 +10,7 @@ use crate::session::turn_context::TurnContext;
use crate::state::ActiveTurn;
use crate::state::TurnState;
use crate::tasks::RegularTask;
use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME;
use anyhow::Context;
use codex_features::Feature;
use codex_otel::GOAL_BUDGET_LIMITED_METRIC;
@@ -317,7 +318,7 @@ impl Session {
turn_context,
tool_name,
} => Box::pin(async move {
if tool_name != codex_tools::UPDATE_GOAL_TOOL_NAME {
if tool_name != UPDATE_GOAL_TOOL_NAME {
self.account_thread_goal_progress(
turn_context,
BudgetLimitSteering::Allowed,
@@ -0,0 +1,88 @@
use codex_code_mode::ToolDefinition as CodeModeToolDefinition;
use codex_tools::FreeformTool;
use codex_tools::FreeformToolFormat;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;
pub(crate) fn create_code_mode_tool(
enabled_tools: &[CodeModeToolDefinition],
namespace_descriptions: &BTreeMap<String, codex_code_mode::ToolNamespaceDescription>,
code_mode_only: bool,
deferred_tools_available: bool,
) -> ToolSpec {
const CODE_MODE_FREEFORM_GRAMMAR: &str = r#"
start: pragma_source | plain_source
pragma_source: PRAGMA_LINE NEWLINE SOURCE
plain_source: SOURCE
PRAGMA_LINE: /[ \t]*\/\/ @exec:[^\r\n]*/
NEWLINE: /\r?\n/
SOURCE: /[\s\S]+/
"#;
ToolSpec::Freeform(FreeformTool {
name: codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
description: codex_code_mode::build_exec_tool_description(
enabled_tools,
namespace_descriptions,
code_mode_only,
deferred_tools_available,
),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: CODE_MODE_FREEFORM_GRAMMAR.to_string(),
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use codex_tools::ToolName;
use pretty_assertions::assert_eq;
#[test]
fn create_code_mode_tool_matches_expected_spec() {
let enabled_tools = vec![codex_code_mode::ToolDefinition {
name: "update_plan".to_string(),
tool_name: ToolName::plain("update_plan"),
description: "Update the plan".to_string(),
kind: codex_code_mode::CodeModeToolKind::Function,
input_schema: None,
output_schema: None,
}];
assert_eq!(
create_code_mode_tool(
&enabled_tools,
&BTreeMap::new(),
/*code_mode_only*/ true,
/*deferred_tools_available*/ false,
),
ToolSpec::Freeform(FreeformTool {
name: codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
description: codex_code_mode::build_exec_tool_description(
&enabled_tools,
&BTreeMap::new(),
/*code_mode_only*/ true,
/*deferred_tools_available*/ false
),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: r#"
start: pragma_source | plain_source
pragma_source: PRAGMA_LINE NEWLINE SOURCE
plain_source: SOURCE
PRAGMA_LINE: /[ \t]*\/\/ @exec:[^\r\n]*/
NEWLINE: /\r?\n/
SOURCE: /[\s\S]+/
"#
.to_string(),
},
})
);
}
}
+2
View File
@@ -1,6 +1,8 @@
mod execute_handler;
pub(crate) mod execute_spec;
mod response_adapter;
mod wait_handler;
pub(crate) mod wait_spec;
use std::collections::HashSet;
use std::sync::Arc;
@@ -0,0 +1,105 @@
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;
pub(crate) fn create_wait_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"cell_id".to_string(),
JsonSchema::string(Some("Identifier of the running exec cell.".to_string())),
),
(
"yield_time_ms".to_string(),
JsonSchema::number(Some(
"How long to wait (in milliseconds) for more output before yielding again."
.to_string(),
)),
),
(
"max_tokens".to_string(),
JsonSchema::number(Some(
"Maximum number of output tokens to return for this wait call.".to_string(),
)),
),
(
"terminate".to_string(),
JsonSchema::boolean(Some(
"Whether to terminate the running exec cell.".to_string(),
)),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: codex_code_mode::WAIT_TOOL_NAME.to_string(),
description: format!(
"Waits on a yielded `{}` cell and returns new output or completion.\n{}",
codex_code_mode::PUBLIC_TOOL_NAME,
codex_code_mode::build_wait_tool_description().trim()
),
strict: false,
parameters: JsonSchema::object(
properties,
Some(vec!["cell_id".to_string()]),
Some(false.into()),
),
output_schema: None,
defer_loading: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn create_wait_tool_matches_expected_spec() {
assert_eq!(
create_wait_tool(),
ToolSpec::Function(ResponsesApiTool {
name: codex_code_mode::WAIT_TOOL_NAME.to_string(),
description: format!(
"Waits on a yielded `{}` cell and returns new output or completion.\n{}",
codex_code_mode::PUBLIC_TOOL_NAME,
codex_code_mode::build_wait_tool_description().trim()
),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
BTreeMap::from([
(
"cell_id".to_string(),
JsonSchema::string(Some(
"Identifier of the running exec cell.".to_string()
)),
),
(
"max_tokens".to_string(),
JsonSchema::number(Some(
"Maximum number of output tokens to return for this wait call."
.to_string(),
)),
),
(
"terminate".to_string(),
JsonSchema::boolean(Some(
"Whether to terminate the running exec cell.".to_string(),
)),
),
(
"yield_time_ms".to_string(),
JsonSchema::number(Some(
"How long to wait (in milliseconds) for more output before yielding again."
.to_string(),
)),
),
]),
Some(vec!["cell_id".to_string()]),
Some(false.into()),
),
output_schema: None,
})
);
}
}
@@ -0,0 +1,107 @@
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;
pub fn create_spawn_agents_on_csv_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"csv_path".to_string(),
JsonSchema::string(Some("Path to the CSV file containing input rows.".to_string())),
),
(
"instruction".to_string(),
JsonSchema::string(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(Some(
"Optional column name to use as stable item id.".to_string(),
)),
),
(
"output_csv_path".to_string(),
JsonSchema::string(Some("Optional output CSV path for exported results.".to_string())),
),
(
"max_concurrency".to_string(),
JsonSchema::number(Some(
"Maximum concurrent workers for this job. Defaults to 16 and is capped by config."
.to_string(),
)),
),
(
"max_workers".to_string(),
JsonSchema::number(Some(
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
)),
),
(
"max_runtime_seconds".to_string(),
JsonSchema::number(Some(
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
.to_string(),
)),
),
(
"output_schema".to_string(),
JsonSchema::object(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, Some(vec!["csv_path".to_string(), "instruction".to_string()]), 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(Some("Identifier of the job.".to_string())),
),
(
"item_id".to_string(),
JsonSchema::string(Some("Identifier of the job item.".to_string())),
),
(
"result".to_string(),
JsonSchema::object(BTreeMap::new(), /*required*/ None, /*additional_properties*/ None),
),
(
"stop".to_string(),
JsonSchema::boolean(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, Some(vec![
"job_id".to_string(),
"item_id".to_string(),
"result".to_string(),
]), Some(false.into())),
output_schema: None,
})
}
#[cfg(test)]
#[path = "agent_jobs_spec_tests.rs"]
mod tests;
@@ -0,0 +1,119 @@
use super::*;
use codex_tools::JsonSchema;
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(BTreeMap::from([
(
"csv_path".to_string(),
JsonSchema::string(Some(
"Path to the CSV file containing input rows.".to_string(),
)),
),
(
"instruction".to_string(),
JsonSchema::string(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(Some(
"Optional column name to use as stable item id.".to_string(),
)),
),
(
"output_csv_path".to_string(),
JsonSchema::string(Some(
"Optional output CSV path for exported results.".to_string(),
)),
),
(
"max_concurrency".to_string(),
JsonSchema::number(Some(
"Maximum concurrent workers for this job. Defaults to 16 and is capped by config."
.to_string(),
)),
),
(
"max_workers".to_string(),
JsonSchema::number(Some(
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
)),
),
(
"max_runtime_seconds".to_string(),
JsonSchema::number(Some(
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
.to_string(),
)),
),
(
"output_schema".to_string(),
JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None,
),
),
]), Some(vec!["csv_path".to_string(), "instruction".to_string()]), 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(BTreeMap::from([
(
"job_id".to_string(),
JsonSchema::string(Some("Identifier of the job.".to_string())),
),
(
"item_id".to_string(),
JsonSchema::string(Some("Identifier of the job item.".to_string())),
),
(
"result".to_string(),
JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None,
),
),
(
"stop".to_string(),
JsonSchema::boolean(Some(
"Optional. When true, cancels the remaining job items after this result is recorded."
.to_string(),
)),
),
]), Some(vec![
"job_id".to_string(),
"item_id".to_string(),
"result".to_string(),
]), Some(false.into())),
output_schema: None,
})
);
}
@@ -0,0 +1,19 @@
start: begin_patch hunk+ end_patch
begin_patch: "*** Begin Patch" LF
end_patch: "*** End Patch" LF?
hunk: add_hunk | delete_hunk | update_hunk
add_hunk: "*** Add File: " filename LF add_line+
delete_hunk: "*** Delete File: " filename LF
update_hunk: "*** Update File: " filename LF change_move? change?
filename: /(.+)/
add_line: "+" /(.*)/ LF -> line
change_move: "*** Move to: " filename LF
change: (change_context | change_line)+ eof_line?
change_context: ("@@" | "@@ " /(.+)/) LF
change_line: ("+" | "-" | " ") /(.*)/ LF
eof_line: "*** End of File" LF
%import common.LF
@@ -21,6 +21,7 @@ use crate::tools::context::ToolPayload;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::handlers::apply_granted_turn_permissions;
use crate::tools::handlers::apply_patch_spec::ApplyPatchToolArgs;
use crate::tools::handlers::parse_arguments;
use crate::tools::hook_names::HookToolName;
use crate::tools::orchestrator::ToolOrchestrator;
@@ -46,7 +47,6 @@ use codex_protocol::protocol::PatchApplyUpdatedEvent;
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use codex_sandboxing::policy_transforms::normalize_additional_permissions;
use codex_tools::ApplyPatchToolArgs;
use codex_tools::ToolName;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -0,0 +1,126 @@
use codex_tools::FreeformTool;
use codex_tools::FreeformToolFormat;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
const APPLY_PATCH_LARK_GRAMMAR: &str = include_str!("apply_patch.lark");
const APPLY_PATCH_JSON_TOOL_DESCRIPTION: &str = r#"Use the `apply_patch` tool to edit files.
Your patch language is a strippeddown, fileoriented diff format designed to be easy to parse and safe to apply. You can think of it as a highlevel envelope:
*** Begin Patch
[ one or more file sections ]
*** End Patch
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
*** Delete File: <path> - remove an existing file. Nothing follows.
*** Update File: <path> - patch an existing file in place (optionally with a rename).
May be immediately followed by *** Move to: <new path> if you want to rename the file.
Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header).
Within a hunk each line starts with:
For instructions on [context_before] and [context_after]:
- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first changes [context_after] lines in the second changes [context_before] lines.
- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:
@@ class BaseClass
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
- If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:
@@ class BaseClass
@@ def method():
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
The full grammar definition is below:
Patch := Begin { FileOp } End
Begin := "*** Begin Patch" NEWLINE
End := "*** End Patch" NEWLINE
FileOp := AddFile | DeleteFile | UpdateFile
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
DeleteFile := "*** Delete File: " path NEWLINE
UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
MoveTo := "*** Move to: " newPath NEWLINE
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
HunkLine := (" " | "-" | "+") text NEWLINE
A full patch can combine several operations:
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file
- File references can only be relative, NEVER ABSOLUTE.
"#;
/// TODO(dylan): deprecate once we get rid of json tool
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApplyPatchToolArgs {
pub input: String,
}
/// Returns a custom tool that can be used to edit files. Well-suited for GPT-5 models
/// https://platform.openai.com/docs/guides/function-calling#custom-tools
pub fn create_apply_patch_freeform_tool() -> ToolSpec {
ToolSpec::Freeform(FreeformTool {
name: "apply_patch".to_string(),
description: "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.".to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: APPLY_PATCH_LARK_GRAMMAR.to_string(),
},
})
}
/// Returns a json tool that can be used to edit files. Should only be used with gpt-oss models
pub fn create_apply_patch_json_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"input".to_string(),
JsonSchema::string(Some(
"The entire contents of the apply_patch command".to_string(),
)),
)]);
ToolSpec::Function(ResponsesApiTool {
name: "apply_patch".to_string(),
description: APPLY_PATCH_JSON_TOOL_DESCRIPTION.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["input".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
#[cfg(test)]
#[path = "apply_patch_spec_tests.rs"]
mod tests;
@@ -0,0 +1,46 @@
use super::*;
use codex_tools::JsonSchema;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn create_apply_patch_freeform_tool_matches_expected_spec() {
assert_eq!(
create_apply_patch_freeform_tool(),
ToolSpec::Freeform(FreeformTool {
name: "apply_patch".to_string(),
description:
"Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON."
.to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: APPLY_PATCH_LARK_GRAMMAR.to_string(),
},
})
);
}
#[test]
fn create_apply_patch_json_tool_matches_expected_spec() {
assert_eq!(
create_apply_patch_json_tool(),
ToolSpec::Function(ResponsesApiTool {
name: "apply_patch".to_string(),
description: APPLY_PATCH_JSON_TOOL_DESCRIPTION.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
BTreeMap::from([(
"input".to_string(),
JsonSchema::string(Some(
"The entire contents of the apply_patch command".to_string(),
),),
)]),
Some(vec!["input".to_string()]),
Some(false.into())
),
output_schema: None,
})
);
}
@@ -3,10 +3,10 @@ use crate::goals::CreateGoalRequest;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::goal_spec::CREATE_GOAL_TOOL_NAME;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_tools::CREATE_GOAL_TOOL_NAME;
use codex_tools::ToolName;
use super::CompletionBudgetReport;
@@ -2,9 +2,9 @@ use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::goal_spec::GET_GOAL_TOOL_NAME;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_tools::GET_GOAL_TOOL_NAME;
use codex_tools::ToolName;
use super::CompletionBudgetReport;
@@ -4,12 +4,12 @@ use crate::goals::SetGoalRequest;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::protocol::ThreadGoalStatus;
use codex_tools::ToolName;
use codex_tools::UPDATE_GOAL_TOOL_NAME;
use super::CompletionBudgetReport;
use super::UpdateGoalArgs;
@@ -0,0 +1,112 @@
//! Responses API tool definitions for persisted thread goals.
//!
//! These specs expose goal read/update primitives to the model while keeping
//! usage accounting system-managed.
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use serde_json::json;
use std::collections::BTreeMap;
pub const GET_GOAL_TOOL_NAME: &str = "get_goal";
pub const CREATE_GOAL_TOOL_NAME: &str = "create_goal";
pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal";
pub fn create_get_goal_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: GET_GOAL_TOOL_NAME.to_string(),
description: "Get the current goal for this thread, including status, budgets, token and elapsed-time usage, and remaining token budget."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(BTreeMap::new(), Some(Vec::new()), Some(false.into())),
output_schema: None,
})
}
pub fn create_create_goal_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"objective".to_string(),
JsonSchema::string(Some(
"Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails."
.to_string(),
)),
),
(
"token_budget".to_string(),
JsonSchema::integer(Some(
"Optional positive token budget for the new active goal.".to_string(),
)),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: CREATE_GOAL_TOOL_NAME.to_string(),
description: format!(
r#"Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks.
Set token_budget only when an explicit token budget is requested. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."#
),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
/*required*/ Some(vec!["objective".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
pub fn create_update_goal_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"status".to_string(),
JsonSchema::string_enum(
vec![json!("complete")],
Some(
"Required. Set to complete only when the objective is achieved and no required work remains."
.to_string(),
),
),
)]);
ToolSpec::Function(ResponsesApiTool {
name: UPDATE_GOAL_TOOL_NAME.to_string(),
description: r#"Update the existing goal.
Use this tool only to mark the goal achieved.
Set status to `complete` only when the objective has actually been achieved and no required work remains.
Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work.
You cannot use this tool to pause, resume, or budget-limit a goal; those status changes are controlled by the user or system.
When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."#
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
/*required*/ Some(vec!["status".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn update_goal_tool_only_exposes_complete_status() {
let ToolSpec::Function(tool) = create_update_goal_tool() else {
panic!("update_goal should be a function tool");
};
let status = tool
.parameters
.properties
.as_ref()
.and_then(|properties| properties.get("status"))
.expect("status property should exist");
assert_eq!(status.enum_values, Some(vec![json!("complete")]));
}
}
@@ -0,0 +1,98 @@
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;
pub fn create_list_mcp_resources_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"server".to_string(),
JsonSchema::string(Some(
"Optional MCP server name. When omitted, lists resources from every configured server."
.to_string(),
)),
),
(
"cursor".to_string(),
JsonSchema::string(Some(
"Opaque cursor returned by a previous list_mcp_resources call for the same server."
.to_string(),
)),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "list_mcp_resources".to_string(),
description: "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
output_schema: None,
})
}
pub fn create_list_mcp_resource_templates_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"server".to_string(),
JsonSchema::string(Some(
"Optional MCP server name. When omitted, lists resource templates from all configured servers."
.to_string(),
)),
),
(
"cursor".to_string(),
JsonSchema::string(Some(
"Opaque cursor returned by a previous list_mcp_resource_templates call for the same server."
.to_string(),
)),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "list_mcp_resource_templates".to_string(),
description: "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
output_schema: None,
})
}
pub fn create_read_mcp_resource_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"server".to_string(),
JsonSchema::string(Some(
"MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources."
.to_string(),
)),
),
(
"uri".to_string(),
JsonSchema::string(Some(
"Resource URI to read. Must be one of the URIs returned by list_mcp_resources."
.to_string(),
)),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "read_mcp_resource".to_string(),
description:
"Read a specific resource from an MCP server given the server name and resource URI."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["server".to_string(), "uri".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
#[cfg(test)]
#[path = "mcp_resource_spec_tests.rs"]
mod tests;
@@ -0,0 +1,96 @@
use super::*;
use codex_tools::JsonSchema;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn list_mcp_resources_tool_matches_expected_spec() {
assert_eq!(
create_list_mcp_resources_tool(),
ToolSpec::Function(ResponsesApiTool {
name: "list_mcp_resources".to_string(),
description: "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(BTreeMap::from([
(
"server".to_string(),
JsonSchema::string(Some(
"Optional MCP server name. When omitted, lists resources from every configured server."
.to_string(),
),),
),
(
"cursor".to_string(),
JsonSchema::string(Some(
"Opaque cursor returned by a previous list_mcp_resources call for the same server."
.to_string(),
),),
),
]), /*required*/ None, Some(false.into())),
output_schema: None,
})
);
}
#[test]
fn list_mcp_resource_templates_tool_matches_expected_spec() {
assert_eq!(
create_list_mcp_resource_templates_tool(),
ToolSpec::Function(ResponsesApiTool {
name: "list_mcp_resource_templates".to_string(),
description: "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(BTreeMap::from([
(
"server".to_string(),
JsonSchema::string(Some(
"Optional MCP server name. When omitted, lists resource templates from all configured servers."
.to_string(),
),),
),
(
"cursor".to_string(),
JsonSchema::string(Some(
"Opaque cursor returned by a previous list_mcp_resource_templates call for the same server."
.to_string(),
),),
),
]), /*required*/ None, Some(false.into())),
output_schema: None,
})
);
}
#[test]
fn read_mcp_resource_tool_matches_expected_spec() {
assert_eq!(
create_read_mcp_resource_tool(),
ToolSpec::Function(ResponsesApiTool {
name: "read_mcp_resource".to_string(),
description:
"Read a specific resource from an MCP server given the server name and resource URI."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(BTreeMap::from([
(
"server".to_string(),
JsonSchema::string(Some(
"MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources."
.to_string(),
),),
),
(
"uri".to_string(),
JsonSchema::string(Some(
"Resource URI to read. Must be one of the URIs returned by list_mcp_resources."
.to_string(),
),),
),
]), Some(vec!["server".to_string(), "uri".to_string()]), Some(false.into())),
output_schema: None,
})
);
}
+12
View File
@@ -1,22 +1,34 @@
pub(crate) mod agent_jobs;
pub(crate) mod agent_jobs_spec;
pub(crate) mod apply_patch;
pub(crate) mod apply_patch_spec;
mod dynamic;
mod goal;
pub(crate) mod goal_spec;
mod mcp;
mod mcp_resource;
pub(crate) mod mcp_resource_spec;
pub(crate) mod multi_agents;
pub(crate) mod multi_agents_common;
pub(crate) mod multi_agents_spec;
pub(crate) mod multi_agents_v2;
mod plan;
pub(crate) mod plan_spec;
mod request_permissions;
mod request_plugin_install;
pub(crate) mod request_plugin_install_spec;
mod request_user_input;
pub(crate) mod request_user_input_spec;
mod shell;
pub(crate) mod shell_spec;
mod test_sync;
pub(crate) mod test_sync_spec;
mod tool_search;
pub(crate) mod tool_search_spec;
mod unavailable_tool;
pub(crate) mod unified_exec;
mod view_image;
pub(crate) mod view_image_spec;
use codex_sandboxing::policy_transforms::intersect_permission_profiles;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
@@ -0,0 +1,763 @@
use codex_protocol::openai_models::ModelPreset;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed.";
const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Optional model override for the new agent. Leave unset to inherit the same model as the parent, which is the preferred default. Only set this when the user explicitly asks for a different model or the task clearly requires one.";
#[derive(Debug, Clone)]
pub struct SpawnAgentToolOptions<'a> {
pub available_models: &'a [ModelPreset],
pub agent_type_description: String,
pub hide_agent_type_model_reasoning: bool,
pub include_usage_hint: bool,
pub usage_hint_text: Option<String>,
pub max_concurrent_threads_per_session: Option<usize>,
}
#[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 = (!options.hide_agent_type_model_reasoning)
.then(|| spawn_agent_models_description(options.available_models));
let return_value_description =
"Returns the spawned agent id plus the user-facing nickname when available.";
let mut properties = spawn_agent_common_properties_v1(&options.agent_type_description);
if options.hide_agent_type_model_reasoning {
hide_spawn_agent_metadata_options(&mut properties);
}
ToolSpec::Function(ResponsesApiTool {
name: "spawn_agent".to_string(),
description: spawn_agent_tool_description(
available_models_description.as_deref(),
return_value_description,
options.include_usage_hint,
options.usage_hint_text,
),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, /*required*/ None, 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 = (!options.hide_agent_type_model_reasoning)
.then(|| spawn_agent_models_description(options.available_models));
let mut properties = spawn_agent_common_properties_v2(&options.agent_type_description);
if options.hide_agent_type_model_reasoning {
hide_spawn_agent_metadata_options(&mut properties);
}
properties.insert(
"task_name".to_string(),
JsonSchema::string(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_v2(
available_models_description.as_deref(),
options.include_usage_hint,
options.usage_hint_text,
options.max_concurrent_threads_per_session,
),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["task_name".to_string(), "message".to_string()]),
Some(false.into()),
),
output_schema: Some(spawn_agent_output_schema_v2(
options.hide_agent_type_model_reasoning,
)),
})
}
pub fn create_send_input_tool_v1() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::string(Some("Agent id to message (from spawn_agent).".to_string())),
),
(
"message".to_string(),
JsonSchema::string(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(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, Some(vec!["target".to_string()]), 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(Some(
"Relative or canonical task name to message (from spawn_agent).".to_string(),
)),
),
(
"message".to_string(),
JsonSchema::string(Some(
"Message text to queue on the target agent.".to_string(),
)),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "send_message".to_string(),
description: "Send a message to an existing agent. The message will be delivered promptly. Does not trigger a new turn."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["target".to_string(), "message".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
pub fn create_followup_task_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::string(Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
)),
),
(
"message".to_string(),
JsonSchema::string(Some(
"Message text to send to the target agent.".to_string(),
)),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "followup_task".to_string(),
description: "Send a message to an existing non-root target agent and trigger a turn in that target. If the target is currently mid-turn, the message is queued and will be used to start the target's next turn, after the current turn completes."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, Some(vec!["target".to_string(), "message".to_string()]), Some(false.into())),
output_schema: None,
})
}
pub fn create_resume_agent_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"id".to_string(),
JsonSchema::string(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, Some(vec!["id".to_string()]), 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 a mailbox update from any live agent, including queued messages and final-status notifications. Does not return the content; returns either a summary of which agents have updates (if any), or a timeout summary if no mailbox update arrives 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(Some(
"Optional task-path prefix (not ending with trailing slash). Accepts the same relative or absolute task-path syntax."
.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, 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(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, Some(vec!["target".to_string()]), 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(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, Some(vec!["target".to_string()]), Some(false.into())),
output_schema: Some(close_agent_output_schema()),
})
}
fn agent_status_output_schema() -> Value {
json!({
"oneOf": [
{
"type": "string",
"enum": ["pending_init", "running", "interrupted", "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(hide_agent_metadata: bool) -> Value {
if hide_agent_metadata {
return json!({
"type": "object",
"properties": {
"task_name": {
"type": "string",
"description": "Canonical task name for the spawned agent."
}
},
"required": ["task_name"],
"additionalProperties": false
});
}
json!({
"type": "object",
"properties": {
"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": ["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 because no mailbox update arrived before the timeout."
}
},
"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(Some(
"Input item type: text, image, local_image, skill, or mention.".to_string(),
)),
),
(
"text".to_string(),
JsonSchema::string(Some("Text content when type is text.".to_string())),
),
(
"image_url".to_string(),
JsonSchema::string(Some("Image URL when type is image.".to_string())),
),
(
"path".to_string(),
JsonSchema::string(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(Some("Display name when type is skill or mention.".to_string())),
),
]);
JsonSchema::array(JsonSchema::object(properties, /*required*/ None, Some(false.into())), Some(
"Structured input items. Use this to pass explicit mentions (for example app:// connector paths)."
.to_string(),
))
}
fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<String, JsonSchema> {
BTreeMap::from([
(
"message".to_string(),
JsonSchema::string(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(Some(agent_type_description.to_string())),
),
(
"fork_context".to_string(),
JsonSchema::boolean(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(Some(
SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION.to_string(),
)),
),
(
"reasoning_effort".to_string(),
JsonSchema::string(Some(
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
.to_string(),
)),
),
])
}
fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<String, JsonSchema> {
BTreeMap::from([
(
"message".to_string(),
JsonSchema::string(Some("Initial plain-text task for the new agent.".to_string())),
),
(
"agent_type".to_string(),
JsonSchema::string(Some(agent_type_description.to_string())),
),
(
"fork_turns".to_string(),
JsonSchema::string(Some(
"Optional number of turns to fork. Defaults to `all`. Use `none`, `all`, or a positive integer string such as `3` to fork only the most recent turns."
.to_string(),
)),
),
(
"model".to_string(),
JsonSchema::string(Some(
SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION.to_string(),
)),
),
(
"reasoning_effort".to_string(),
JsonSchema::string(Some(
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
.to_string(),
)),
),
])
}
fn hide_spawn_agent_metadata_options(properties: &mut BTreeMap<String, JsonSchema>) {
properties.remove("agent_type");
properties.remove("model");
properties.remove("reasoning_effort");
}
fn spawn_agent_tool_description(
available_models_description: Option<&str>,
return_value_description: &str,
include_usage_hint: bool,
usage_hint_text: Option<String>,
) -> String {
let agent_role_guidance = available_models_description.unwrap_or_default();
let tool_description = format!(
r#"
{agent_role_guidance}
Spawn a sub-agent for a well-scoped task. {return_value_description} {SPAWN_AGENT_INHERITED_MODEL_GUIDANCE}"#
);
if !include_usage_hint {
return tool_description;
}
if let Some(usage_hint_text) = usage_hint_text {
return format!(
r#"
{tool_description}
{usage_hint_text}"#
);
}
let agent_role_usage_hint = available_models_description
.map(|_| {
"Agent-role guidance below only helps choose which agent to use after spawning is already authorized; it never authorizes spawning by itself."
})
.unwrap_or_default();
format!(
r#"
{tool_description}
This spawn_agent tool provides you access to sub-agents that inherit your current model by default. Do not set the `model` field unless the user explicitly asks for a different model or there is a clear task-specific reason. You should follow the rules and guidelines below to use this tool.
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_usage_hint}
### 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 a 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_tool_description_v2(
available_models_description: Option<&str>,
include_usage_hint: bool,
usage_hint_text: Option<String>,
max_concurrent_threads_per_session: Option<usize>,
) -> String {
let agent_role_guidance = available_models_description.unwrap_or_default();
let concurrency_guidance = max_concurrent_threads_per_session
.map(|limit| {
format!(
"This session is configured with `max_concurrent_threads_per_session = {limit}` for concurrently open agent threads."
)
})
.unwrap_or_default();
let tool_description = format!(
r#"
{agent_role_guidance}
Spawns an agent to work on the specified task. If your current task is `/root/task1` and you spawn_agent with task_name "task_3" the agent will have canonical task name `/root/task1/task_3`.
You are then able to refer to this agent as `task_3` or `/root/task1/task_3` interchangeably. However an agent `/root/task2/task_3` would only be able to communicate with this agent via its canonical name `/root/task1/task_3`.
The spawned agent will have the same tools as you and the ability to spawn its own subagents.
{SPAWN_AGENT_INHERITED_MODEL_GUIDANCE}
It will be able to send you and other running agents messages, and its final answer will be provided to you when it finishes.
The new agent's canonical task name will be provided to it along with the message.
{concurrency_guidance}"#
);
if !include_usage_hint {
return tool_description;
}
if let Some(usage_hint_text) = usage_hint_text {
return format!(
r#"
{tool_description}
{usage_hint_text}"#
);
}
tool_description
}
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 model overrides are currently loaded.".to_string();
}
let model_descriptions = 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");
format!(
"Available model overrides (optional; inherited parent model is preferred):\n{model_descriptions}"
)
}
fn wait_agent_tool_parameters_v1(options: WaitAgentTimeoutOptions) -> JsonSchema {
let properties = BTreeMap::from([
(
"targets".to_string(),
JsonSchema::array(
JsonSchema::string(/*description*/ None),
Some(
"Agent ids to wait on. Pass multiple ids to wait for whichever finishes first."
.to_string(),
),
),
),
(
"timeout_ms".to_string(),
JsonSchema::number(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,
Some(vec!["targets".to_string()]),
Some(false.into()),
)
}
fn wait_agent_tool_parameters_v2(options: WaitAgentTimeoutOptions) -> JsonSchema {
let properties = BTreeMap::from([(
"timeout_ms".to_string(),
JsonSchema::number(Some(format!(
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
))),
)]);
JsonSchema::object(properties, /*required*/ None, Some(false.into()))
}
#[cfg(test)]
#[path = "multi_agents_spec_tests.rs"]
mod tests;
@@ -0,0 +1,287 @@
use super::*;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_tools::JsonSchemaPrimitiveType;
use codex_tools::JsonSchemaType;
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,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
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(),
hide_agent_type_model_reasoning: false,
include_usage_hint: true,
usage_hint_text: None,
max_concurrent_threads_per_session: Some(4),
});
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
output_schema,
..
}) = tool
else {
panic!("spawn_agent should be a function tool");
};
assert_eq!(
parameters.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
);
let properties = parameters
.properties
.as_ref()
.expect("spawn_agent should use object params");
assert!(description.contains("Spawns an agent to work on the specified task."));
assert!(description.contains("The spawned agent will have the same tools as you"));
assert!(description.contains("`max_concurrent_threads_per_session = 4`"));
assert!(description.contains(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE));
assert!(
description
.contains("Available model overrides (optional; inherited parent model is preferred):")
);
assert!(description.contains("visible display (`visible-model`)"));
assert!(!description.contains("hidden display (`hidden-model`)"));
assert!(properties.contains_key("task_name"));
assert!(properties.contains_key("message"));
assert!(properties.contains_key("fork_turns"));
assert!(!properties.contains_key("items"));
assert!(!properties.contains_key("fork_context"));
assert_eq!(
properties.get("agent_type"),
Some(&JsonSchema::string(Some("role help".to_string())))
);
assert_eq!(
properties
.get("model")
.and_then(|schema| schema.description.as_deref()),
Some(SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION)
);
assert_eq!(
parameters.required.as_ref(),
Some(&vec!["task_name".to_string(), "message".to_string()])
);
assert_eq!(
output_schema.expect("spawn_agent output schema")["required"],
json!(["task_name", "nickname"])
);
}
#[test]
fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
let tool = create_spawn_agent_tool_v1(SpawnAgentToolOptions {
available_models: &[],
agent_type_description: "role help".to_string(),
hide_agent_type_model_reasoning: false,
include_usage_hint: true,
usage_hint_text: None,
max_concurrent_threads_per_session: None,
});
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
panic!("spawn_agent should be a function tool");
};
assert_eq!(
parameters.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
);
let properties = parameters
.properties
.as_ref()
.expect("spawn_agent should use object params");
assert!(properties.contains_key("fork_context"));
assert!(!properties.contains_key("fork_turns"));
assert_eq!(
properties
.get("model")
.and_then(|schema| schema.description.as_deref()),
Some(SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION)
);
}
#[test]
fn send_message_tool_requires_message_and_has_no_output_schema() {
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
..
}) = create_send_message_tool()
else {
panic!("send_message should be a function tool");
};
assert_eq!(
parameters.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
);
let properties = parameters
.properties
.as_ref()
.expect("send_message should use object params");
assert!(properties.contains_key("target"));
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("interrupt"));
assert!(!properties.contains_key("items"));
assert_eq!(
properties
.get("target")
.and_then(|schema| schema.description.as_deref()),
Some("Relative or canonical task name to message (from spawn_agent).")
);
assert_eq!(
parameters.required.as_ref(),
Some(&vec!["target".to_string(), "message".to_string()])
);
assert_eq!(output_schema, None);
}
#[test]
fn followup_task_tool_requires_message_and_has_no_output_schema() {
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
..
}) = create_followup_task_tool()
else {
panic!("followup_task should be a function tool");
};
assert_eq!(
parameters.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
);
let properties = parameters
.properties
.as_ref()
.expect("followup_task should use object params");
assert!(properties.contains_key("target"));
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("items"));
assert_eq!(
parameters.required.as_ref(),
Some(&vec!["target".to_string(), "message".to_string()])
);
assert_eq!(output_schema, None);
}
#[test]
fn wait_agent_tool_v2_uses_timeout_only_summary_output() {
let ToolSpec::Function(ResponsesApiTool {
description,
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");
};
assert_eq!(
parameters.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
);
let properties = parameters
.properties
.as_ref()
.expect("wait_agent should use object params");
assert!(!properties.contains_key("targets"));
assert!(properties.contains_key("timeout_ms"));
assert!(description.contains(
"Does not return the content; returns either a summary of which agents have updates (if any)"
));
assert_eq!(
properties
.get("timeout_ms")
.and_then(|schema| schema.description.as_deref()),
Some("Optional timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.")
);
assert_eq!(parameters.required.as_ref(), None);
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");
};
assert_eq!(
parameters.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
);
let properties = parameters
.properties
.as_ref()
.expect("list_agents should use object params");
assert!(properties.contains_key("path_prefix"));
assert_eq!(
properties
.get("path_prefix")
.and_then(|schema| schema.description.as_deref()),
Some(
"Optional task-path prefix (not ending with trailing slash). Accepts the same relative or absolute task-path syntax."
)
);
assert_eq!(
output_schema.expect("list_agents output schema")["properties"]["agents"]["items"]["required"],
json!(["agent_name", "agent_status", "last_task_message"])
);
}
#[test]
fn list_agents_tool_status_schema_includes_interrupted() {
let ToolSpec::Function(ResponsesApiTool { output_schema, .. }) = create_list_agents_tool()
else {
panic!("list_agents should be a function tool");
};
assert_eq!(
output_schema.expect("list_agents output schema")["properties"]["agents"]["items"]["properties"]
["agent_status"]["allOf"][0]["oneOf"][0]["enum"],
json!([
"pending_init",
"running",
"interrupted",
"shutdown",
"not_found"
])
);
}
@@ -0,0 +1,49 @@
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;
pub fn create_update_plan_tool() -> ToolSpec {
let plan_item_properties = BTreeMap::from([
("step".to_string(), JsonSchema::string(/*description*/ None)),
(
"status".to_string(),
JsonSchema::string(Some("One of: pending, in_progress, completed".to_string())),
),
]);
let properties = BTreeMap::from([
(
"explanation".to_string(),
JsonSchema::string(/*description*/ None),
),
(
"plan".to_string(),
JsonSchema::array(
JsonSchema::object(
plan_item_properties,
Some(vec!["step".to_string(), "status".to_string()]),
Some(false.into()),
),
Some("The list of steps".to_string()),
),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "update_plan".to_string(),
description: r#"Updates the task plan.
Provide an optional explanation and a list of plan items, each with a step and status.
At most one step can be in_progress at a time.
"#
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["plan".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
@@ -0,0 +1,230 @@
use codex_tools::DiscoverableToolType;
use codex_tools::JsonSchema;
use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
use codex_tools::RequestPluginInstallEntry;
use codex_tools::ResponsesApiTool;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;
pub(crate) fn create_request_plugin_install_tool(
discoverable_tools: &[RequestPluginInstallEntry],
) -> ToolSpec {
let properties = BTreeMap::from([
(
"tool_type".to_string(),
JsonSchema::string(Some(
"Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
.to_string(),
)),
),
(
"action_type".to_string(),
JsonSchema::string(Some("Suggested action for the tool. Use \"install\".".to_string())),
),
(
"tool_id".to_string(),
JsonSchema::string(Some("Connector or plugin id to suggest.".to_string())),
),
(
"suggest_reason".to_string(),
JsonSchema::string(Some(
"Concise one-line user-facing reason why this plugin or connector can help with the current request."
.to_string(),
)),
),
]);
let discoverable_tools = format_discoverable_tools(discoverable_tools);
let description = format!(
"# Request plugin/connector install\n\nUse this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.\n\nUse this ONLY when all of the following are true:\n- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.\n- `{TOOL_SEARCH_TOOL_NAME}` is not available, or it has already been called and did not find or make the requested tool callable.\n- The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list.\n\nDo not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful. Only use when the user explicitly asks to use that exact listed plugin or connector.\n\nKnown plugins/connectors available to install:\n{discoverable_tools}\n\nWorkflow:\n\n1. Check the current context and active `tools` list first. If current active tools aren't relevant and `{TOOL_SEARCH_TOOL_NAME}` is available, only call this tool after `{TOOL_SEARCH_TOOL_NAME}` has already been tried and found no relevant tool.\n2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.\n3. If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.\n4. If one plugin or connector clearly fits, call `{REQUEST_PLUGIN_INSTALL_TOOL_NAME}` with:\n - `tool_type`: `connector` or `plugin`\n - `action_type`: `install`\n - `tool_id`: exact id from the known plugin/connector list above\n - `suggest_reason`: concise one-line user-facing reason this plugin or connector can help with the current request\n5. After the request flow completes:\n - if the user finished the install flow, continue by searching again or using the newly available plugin or connector\n - if the user did not finish, continue without that plugin or connector, and don't request it again unless the user explicitly asks for it.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools."
);
ToolSpec::Function(ResponsesApiTool {
name: REQUEST_PLUGIN_INSTALL_TOOL_NAME.to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec![
"tool_type".to_string(),
"action_type".to_string(),
"tool_id".to_string(),
"suggest_reason".to_string(),
]),
Some(false.into()),
),
output_schema: None,
})
}
fn format_discoverable_tools(discoverable_tools: &[RequestPluginInstallEntry]) -> String {
let mut discoverable_tools = discoverable_tools.to_vec();
discoverable_tools.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
discoverable_tools
.into_iter()
.map(|tool| {
let description = tool_description_or_fallback(&tool);
format!(
"- {} (id: `{}`, type: {}, action: install): {}",
tool.name,
tool.id,
discoverable_tool_type_str(tool.tool_type),
description
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn tool_description_or_fallback(tool: &RequestPluginInstallEntry) -> String {
if let Some(description) = tool
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
{
return description.to_string();
}
match tool.tool_type {
DiscoverableToolType::Connector => "No description provided.".to_string(),
DiscoverableToolType::Plugin => plugin_summary(tool),
}
}
fn plugin_summary(tool: &RequestPluginInstallEntry) -> String {
let mut capabilities = Vec::new();
if tool.has_skills {
capabilities.push("skills".to_string());
}
if !tool.mcp_server_names.is_empty() {
capabilities.push(format!("MCP servers: {}", tool.mcp_server_names.join(", ")));
}
if !tool.app_connector_ids.is_empty() {
capabilities.push(format!(
"app connectors: {}",
tool.app_connector_ids.join(", ")
));
}
if capabilities.is_empty() {
"No description provided.".to_string()
} else {
capabilities.join("; ")
}
}
fn discoverable_tool_type_str(tool_type: DiscoverableToolType) -> &'static str {
match tool_type {
DiscoverableToolType::Connector => "connector",
DiscoverableToolType::Plugin => "plugin",
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_tools::JsonSchema;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn create_request_plugin_install_tool_uses_plugin_summary_fallback() {
let expected_description = concat!(
"# Request plugin/connector install\n\n",
"Use this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.\n\n",
"Use this ONLY when all of the following are true:\n",
"- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.\n",
"- `tool_search` is not available, or it has already been called and did not find or make the requested tool callable.\n",
"- The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list.\n\n",
"Do not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful. Only use when the user explicitly asks to use that exact listed plugin or connector.\n\n",
"Known plugins/connectors available to install:\n",
"- GitHub (id: `github`, type: plugin, action: install): skills; MCP servers: github-mcp; app connectors: github-app\n",
"- Slack (id: `slack@openai-curated`, type: connector, action: install): No description provided.\n\n",
"Workflow:\n\n",
"1. Check the current context and active `tools` list first. If current active tools aren't relevant and `tool_search` is available, only call this tool after `tool_search` has already been tried and found no relevant tool.\n",
"2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.\n",
"3. If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.\n",
"4. If one plugin or connector clearly fits, call `request_plugin_install` with:\n",
" - `tool_type`: `connector` or `plugin`\n",
" - `action_type`: `install`\n",
" - `tool_id`: exact id from the known plugin/connector list above\n",
" - `suggest_reason`: concise one-line user-facing reason this plugin or connector can help with the current request\n",
"5. After the request flow completes:\n",
" - if the user finished the install flow, continue by searching again or using the newly available plugin or connector\n",
" - if the user did not finish, continue without that plugin or connector, and don't request it again unless the user explicitly asks for it.\n\n",
"IMPORTANT: DO NOT call this tool in parallel with other tools.",
);
assert_eq!(
create_request_plugin_install_tool(&[
RequestPluginInstallEntry {
id: "slack@openai-curated".to_string(),
name: "Slack".to_string(),
description: None,
tool_type: DiscoverableToolType::Connector,
has_skills: false,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
},
RequestPluginInstallEntry {
id: "github".to_string(),
name: "GitHub".to_string(),
description: None,
tool_type: DiscoverableToolType::Plugin,
has_skills: true,
mcp_server_names: vec!["github-mcp".to_string()],
app_connector_ids: vec!["github-app".to_string()],
},
]),
ToolSpec::Function(ResponsesApiTool {
name: "request_plugin_install".to_string(),
description: expected_description.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(BTreeMap::from([
(
"action_type".to_string(),
JsonSchema::string(Some(
"Suggested action for the tool. Use \"install\"."
.to_string(),
),),
),
(
"suggest_reason".to_string(),
JsonSchema::string(Some(
"Concise one-line user-facing reason why this plugin or connector can help with the current request."
.to_string(),
),),
),
(
"tool_id".to_string(),
JsonSchema::string(Some(
"Connector or plugin id to suggest."
.to_string(),
),),
),
(
"tool_type".to_string(),
JsonSchema::string(Some(
"Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
.to_string(),
),),
),
]), Some(vec![
"tool_type".to_string(),
"action_type".to_string(),
"tool_id".to_string(),
"suggest_reason".to_string(),
]), Some(false.into())),
output_schema: None,
})
);
}
}
@@ -3,14 +3,14 @@ use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::request_user_input_spec::REQUEST_USER_INPUT_TOOL_NAME;
use crate::tools::handlers::request_user_input_spec::normalize_request_user_input_args;
use crate::tools::handlers::request_user_input_spec::request_user_input_unavailable_message;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::config_types::ModeKind;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_tools::REQUEST_USER_INPUT_TOOL_NAME;
use codex_tools::ToolName;
use codex_tools::normalize_request_user_input_args;
use codex_tools::request_user_input_unavailable_message;
pub struct RequestUserInputHandler {
pub available_modes: Vec<ModeKind>,
@@ -0,0 +1,140 @@
use codex_protocol::config_types::ModeKind;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;
pub const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input";
pub fn create_request_user_input_tool(description: String) -> ToolSpec {
let option_props = BTreeMap::from([
(
"label".to_string(),
JsonSchema::string(Some("User-facing label (1-5 words).".to_string())),
),
(
"description".to_string(),
JsonSchema::string(Some(
"One short sentence explaining impact/tradeoff if selected.".to_string(),
)),
),
]);
let options_schema = JsonSchema::array(JsonSchema::object(
option_props,
Some(vec!["label".to_string(), "description".to_string()]),
Some(false.into()),
), 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(),
));
let question_props = BTreeMap::from([
(
"id".to_string(),
JsonSchema::string(Some(
"Stable identifier for mapping answers (snake_case).".to_string(),
)),
),
(
"header".to_string(),
JsonSchema::string(Some(
"Short header label shown in the UI (12 or fewer chars).".to_string(),
)),
),
(
"question".to_string(),
JsonSchema::string(Some(
"Single-sentence prompt shown to the user.".to_string(),
)),
),
("options".to_string(), options_schema),
]);
let questions_schema = JsonSchema::array(
JsonSchema::object(
question_props,
Some(vec![
"id".to_string(),
"header".to_string(),
"question".to_string(),
"options".to_string(),
]),
Some(false.into()),
),
Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()),
);
let properties = BTreeMap::from([("questions".to_string(), questions_schema)]);
ToolSpec::Function(ResponsesApiTool {
name: REQUEST_USER_INPUT_TOOL_NAME.to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["questions".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
pub fn request_user_input_unavailable_message(
mode: ModeKind,
available_modes: &[ModeKind],
) -> Option<String> {
if available_modes.contains(&mode) {
None
} else {
let mode_name = mode.display_name();
Some(format!(
"request_user_input is unavailable in {mode_name} mode"
))
}
}
pub fn normalize_request_user_input_args(
mut args: RequestUserInputArgs,
) -> Result<RequestUserInputArgs, String> {
let missing_options = args
.questions
.iter()
.any(|question| question.options.as_ref().is_none_or(Vec::is_empty));
if missing_options {
return Err("request_user_input requires non-empty options for every question".to_string());
}
for question in &mut args.questions {
question.is_other = true;
}
Ok(args)
}
pub fn request_user_input_tool_description(available_modes: &[ModeKind]) -> String {
let allowed_modes = format_allowed_modes(available_modes);
format!(
"Request user input for one to three short questions and wait for the response. This tool is only available in {allowed_modes}."
)
}
fn format_allowed_modes(available_modes: &[ModeKind]) -> String {
let mode_names: Vec<&str> = available_modes
.iter()
.map(|mode| mode.display_name())
.collect();
match mode_names.as_slice() {
[] => "no modes".to_string(),
[mode] => format!("{mode} mode"),
[first, second] => format!("{first} or {second} mode"),
[..] => format!("modes: {}", mode_names.join(",")),
}
}
#[cfg(test)]
#[path = "request_user_input_spec_tests.rs"]
mod tests;
@@ -0,0 +1,145 @@
use super::*;
use codex_features::Feature;
use codex_features::Features;
use codex_protocol::config_types::ModeKind;
use codex_tools::JsonSchema;
use codex_tools::request_user_input_available_modes;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
fn default_mode_enabled_available_modes() -> Vec<ModeKind> {
let mut features = Features::with_defaults();
features.enable(Feature::DefaultModeRequestUserInput);
request_user_input_available_modes(&features)
}
fn default_available_modes() -> Vec<ModeKind> {
request_user_input_available_modes(&Features::with_defaults())
}
#[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(BTreeMap::from([(
"questions".to_string(),
JsonSchema::array(
JsonSchema::object(
BTreeMap::from([
(
"header".to_string(),
JsonSchema::string(Some(
"Short header label shown in the UI (12 or fewer chars)."
.to_string(),
)),
),
(
"id".to_string(),
JsonSchema::string(Some(
"Stable identifier for mapping answers (snake_case)."
.to_string(),
)),
),
(
"options".to_string(),
JsonSchema::array(
JsonSchema::object(
BTreeMap::from([
(
"description".to_string(),
JsonSchema::string(Some(
"One short sentence explaining impact/tradeoff if selected."
.to_string(),
)),
),
(
"label".to_string(),
JsonSchema::string(Some(
"User-facing label (1-5 words)."
.to_string(),
)),
),
]),
Some(vec![
"label".to_string(),
"description".to_string(),
]),
Some(false.into()),
),
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(),
),
),
),
(
"question".to_string(),
JsonSchema::string(Some(
"Single-sentence prompt shown to the user.".to_string(),
)),
),
]),
Some(vec![
"id".to_string(),
"header".to_string(),
"question".to_string(),
"options".to_string(),
]),
Some(false.into()),
),
Some(
"Questions to show the user. Prefer 1 and do not exceed 3".to_string(),
),
),
)]), Some(vec!["questions".to_string()]), Some(false.into())),
output_schema: None,
})
);
}
#[test]
fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() {
assert_eq!(
request_user_input_unavailable_message(ModeKind::Plan, &default_available_modes()),
None
);
assert_eq!(
request_user_input_unavailable_message(ModeKind::Default, &default_available_modes()),
Some("request_user_input is unavailable in Default mode".to_string())
);
assert_eq!(
request_user_input_unavailable_message(
ModeKind::Default,
&default_mode_enabled_available_modes()
),
None
);
assert_eq!(
request_user_input_unavailable_message(ModeKind::Execute, &default_available_modes()),
Some("request_user_input is unavailable in Execute mode".to_string())
);
assert_eq!(
request_user_input_unavailable_message(
ModeKind::PairProgramming,
&default_available_modes()
),
Some("request_user_input is unavailable in Pair Programming mode".to_string())
);
}
#[test]
fn request_user_input_tool_description_mentions_available_modes() {
assert_eq!(
request_user_input_tool_description(&default_available_modes()),
"Request user input for one to three short questions and wait for the response. This tool is only available in Plan mode.".to_string()
);
assert_eq!(
request_user_input_tool_description(&default_mode_enabled_available_modes()),
"Request user input for one to three short questions and wait for the response. This tool is only available in Default or Plan mode.".to_string()
);
}
@@ -0,0 +1,453 @@
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::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,
}
#[cfg(test)]
pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec {
create_exec_command_tool_with_environment_id(options, /*include_environment_id*/ false)
}
pub fn create_local_shell_tool() -> ToolSpec {
ToolSpec::LocalShell {}
}
pub(crate) fn create_exec_command_tool_with_environment_id(
options: CommandToolOptions,
include_environment_id: bool,
) -> ToolSpec {
let mut properties = BTreeMap::from([
(
"cmd".to_string(),
JsonSchema::string(Some("Shell command to execute.".to_string())),
),
(
"workdir".to_string(),
JsonSchema::string(Some(
"Optional working directory to run the command in; defaults to the turn cwd."
.to_string(),
)),
),
(
"shell".to_string(),
JsonSchema::string(Some(
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
)),
),
(
"tty".to_string(),
JsonSchema::boolean(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(Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
)),
),
(
"max_output_tokens".to_string(),
JsonSchema::number(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(Some(
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
)),
);
}
if include_environment_id {
properties.insert(
"environment_id".to_string(),
JsonSchema::string(Some(
"Optional environment id from the <environment_context> block. If omitted, uses the primary environment.".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_shell_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,
Some(vec!["cmd".to_string()]),
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(Some(
"Identifier of the running unified exec session.".to_string(),
)),
),
(
"chars".to_string(),
JsonSchema::string(Some(
"Bytes to write to stdin (may be empty to poll).".to_string(),
)),
),
(
"yield_time_ms".to_string(),
JsonSchema::number(Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
)),
),
(
"max_output_tokens".to_string(),
JsonSchema::number(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,
Some(vec!["session_id".to_string()]),
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(
JsonSchema::string(/*description*/ None),
Some("The command to execute".to_string()),
),
),
(
"workdir".to_string(),
JsonSchema::string(Some(
"The working directory to execute the command in".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::number(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_shell_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,
Some(vec!["command".to_string()]),
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(Some(
"The shell script to execute in the user's default shell".to_string(),
)),
),
(
"workdir".to_string(),
JsonSchema::string(Some(
"The working directory to execute the command in".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::number(Some(
"The timeout for the command in milliseconds".to_string(),
)),
),
]);
if options.allow_login_shell {
properties.insert(
"login".to_string(),
JsonSchema::boolean(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_shell_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,
Some(vec!["command".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
pub fn create_request_permissions_tool(description: String) -> ToolSpec {
let properties = BTreeMap::from([
(
"reason".to_string(),
JsonSchema::string(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,
Some(vec!["permissions".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
pub fn request_permissions_tool_description() -> String {
"Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope."
.to_string()
}
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(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(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(JsonSchema::string(/*description*/ None), 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(
BTreeMap::from([
("network".to_string(), network_permissions_schema()),
("file_system".to_string(), file_system_permissions_schema()),
]),
/*required*/ None,
Some(false.into()),
)
}
fn network_permissions_schema() -> JsonSchema {
JsonSchema::object(
BTreeMap::from([(
"enabled".to_string(),
JsonSchema::boolean(Some("Set to true to request network access.".to_string())),
)]),
/*required*/ None,
Some(false.into()),
)
}
fn file_system_permissions_schema() -> JsonSchema {
JsonSchema::object(
BTreeMap::from([
(
"read".to_string(),
JsonSchema::array(
JsonSchema::string(/*description*/ None),
Some("Absolute paths to grant read access to.".to_string()),
),
),
(
"write".to_string(),
JsonSchema::array(
JsonSchema::string(/*description*/ None),
Some("Absolute paths to grant write access to.".to_string()),
),
),
]),
/*required*/ None,
Some(false.into()),
)
}
fn windows_shell_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.
- When using `Start-Process` to launch a background helper or service, pass `-WindowStyle Hidden` unless the user explicitly asked for a visible interactive window. Use visible windows only for interactive tools the user needs to see or control."#
}
#[cfg(test)]
#[path = "shell_spec_tests.rs"]
mod tests;
@@ -0,0 +1,402 @@
use super::*;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
fn windows_shell_guidance_description() -> String {
format!("\n\n{}", windows_shell_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_guidance_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(JsonSchema::string(/*description*/ None), Some("The command to execute".to_string())),
),
(
"workdir".to_string(),
JsonSchema::string(Some("The working directory to execute the command in".to_string())),
),
(
"timeout_ms".to_string(),
JsonSchema::number(Some("The timeout for the command in milliseconds".to_string())),
),
(
"sandbox_permissions".to_string(),
JsonSchema::string(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(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(JsonSchema::string(/*description*/ None), 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,
Some(vec!["command".to_string()]),
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_guidance_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(Some("Shell command to execute.".to_string())),
),
(
"workdir".to_string(),
JsonSchema::string(Some(
"Optional working directory to run the command in; defaults to the turn cwd."
.to_string(),
)),
),
(
"shell".to_string(),
JsonSchema::string(Some(
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
)),
),
(
"tty".to_string(),
JsonSchema::boolean(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(Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
)),
),
(
"max_output_tokens".to_string(),
JsonSchema::number(Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
)),
),
(
"login".to_string(),
JsonSchema::boolean(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,
Some(vec!["cmd".to_string()]),
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(Some(
"Identifier of the running unified exec session.".to_string(),
)),
),
(
"chars".to_string(),
JsonSchema::string(Some(
"Bytes to write to stdin (may be empty to poll).".to_string(),
)),
),
(
"yield_time_ms".to_string(),
JsonSchema::number(Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
)),
),
(
"max_output_tokens".to_string(),
JsonSchema::number(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,
Some(vec!["session_id".to_string()]),
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(
JsonSchema::string(/*description*/ None),
Some("The command to execute".to_string()),
),
),
(
"workdir".to_string(),
JsonSchema::string(Some(
"The working directory to execute the command in".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::number(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_shell_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,
Some(vec!["command".to_string()]),
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(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,
Some(vec!["permissions".to_string()]),
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_guidance_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(Some(
"The shell script to execute in the user's default shell".to_string(),
)),
),
(
"workdir".to_string(),
JsonSchema::string(Some(
"The working directory to execute the command in".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::number(Some(
"The timeout for the command in milliseconds".to_string(),
)),
),
(
"login".to_string(),
JsonSchema::boolean(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,
Some(vec!["command".to_string()]),
Some(false.into())
),
output_schema: None,
})
);
}
@@ -0,0 +1,63 @@
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;
pub fn create_test_sync_tool() -> ToolSpec {
let barrier_properties = BTreeMap::from([
(
"id".to_string(),
JsonSchema::string(Some(
"Identifier shared by concurrent calls that should rendezvous".to_string(),
)),
),
(
"participants".to_string(),
JsonSchema::number(Some(
"Number of tool calls that must arrive before the barrier opens".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::number(Some(
"Maximum time in milliseconds to wait at the barrier".to_string(),
)),
),
]);
let properties = BTreeMap::from([
(
"sleep_before_ms".to_string(),
JsonSchema::number(Some(
"Optional delay in milliseconds before any other action".to_string(),
)),
),
(
"sleep_after_ms".to_string(),
JsonSchema::number(Some(
"Optional delay in milliseconds after completing the barrier".to_string(),
)),
),
(
"barrier".to_string(),
JsonSchema::object(
barrier_properties,
Some(vec!["id".to_string(), "participants".to_string()]),
Some(false.into()),
),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "test_sync_tool".to_string(),
description: "Internal synchronization helper used by Codex integration tests.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
output_schema: None,
})
}
#[cfg(test)]
#[path = "test_sync_spec_tests.rs"]
mod tests;
@@ -0,0 +1,64 @@
use super::*;
use codex_tools::JsonSchema;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn test_sync_tool_matches_expected_spec() {
assert_eq!(
create_test_sync_tool(),
ToolSpec::Function(ResponsesApiTool {
name: "test_sync_tool".to_string(),
description: "Internal synchronization helper used by Codex integration tests."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(BTreeMap::from([
(
"barrier".to_string(),
JsonSchema::object(
BTreeMap::from([
(
"id".to_string(),
JsonSchema::string(Some(
"Identifier shared by concurrent calls that should rendezvous"
.to_string(),
)),
),
(
"participants".to_string(),
JsonSchema::number(Some(
"Number of tool calls that must arrive before the barrier opens"
.to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::number(Some(
"Maximum time in milliseconds to wait at the barrier"
.to_string(),
)),
),
]),
Some(vec!["id".to_string(), "participants".to_string()]),
Some(false.into()),
),
),
(
"sleep_after_ms".to_string(),
JsonSchema::number(Some(
"Optional delay in milliseconds after completing the barrier"
.to_string(),
)),
),
(
"sleep_before_ms".to_string(),
JsonSchema::number(Some(
"Optional delay in milliseconds before any other action".to_string(),
)),
),
]), /*required*/ None, Some(false.into())),
output_schema: None,
})
);
}
@@ -0,0 +1,113 @@
use codex_tools::JsonSchema;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::ToolSearchSourceInfo;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;
pub(crate) fn create_tool_search_tool(
searchable_sources: &[ToolSearchSourceInfo],
default_limit: usize,
) -> ToolSpec {
let properties = BTreeMap::from([
(
"query".to_string(),
JsonSchema::string(Some("Search query for deferred tools.".to_string())),
),
(
"limit".to_string(),
JsonSchema::number(Some(format!(
"Maximum number of tools to return (defaults to {default_limit})."
))),
),
]);
let mut source_descriptions = BTreeMap::new();
for source in searchable_sources {
source_descriptions
.entry(source.name.clone())
.and_modify(|existing: &mut Option<String>| {
if existing.is_none() {
*existing = source.description.clone();
}
})
.or_insert(source.description.clone());
}
let source_descriptions = if source_descriptions.is_empty() {
"None currently enabled.".to_string()
} else {
source_descriptions
.into_iter()
.map(|(name, description)| match description {
Some(description) => format!("- {name}: {description}"),
None => format!("- {name}"),
})
.collect::<Vec<_>>()
.join("\n")
};
let description = format!(
"# Tool discovery\n\nSearches over deferred tool metadata with BM25 and exposes matching tools for the next model call.\n\nYou have access to tools from the following sources:\n{source_descriptions}\nSome of the tools may not have been provided to you upfront, and you should use this tool (`{TOOL_SEARCH_TOOL_NAME}`) to search for the required tools. For MCP tool discovery, always use `{TOOL_SEARCH_TOOL_NAME}` instead of `list_mcp_resources` or `list_mcp_resource_templates`."
);
ToolSpec::ToolSearch {
execution: "client".to_string(),
description,
parameters: JsonSchema::object(
properties,
Some(vec!["query".to_string()]),
Some(false.into()),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_tools::JsonSchema;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn create_tool_search_tool_deduplicates_and_renders_enabled_sources() {
assert_eq!(
create_tool_search_tool(
&[
ToolSearchSourceInfo {
name: "Google Drive".to_string(),
description: Some(
"Use Google Drive as the single entrypoint for Drive, Docs, Sheets, and Slides work."
.to_string(),
),
},
ToolSearchSourceInfo {
name: "Google Drive".to_string(),
description: None,
},
ToolSearchSourceInfo {
name: "docs".to_string(),
description: None,
},
],
/*default_limit*/ 8,
),
ToolSpec::ToolSearch {
execution: "client".to_string(),
description: "# Tool discovery\n\nSearches over deferred tool metadata with BM25 and exposes matching tools for the next model call.\n\nYou have access to tools from the following sources:\n- Google Drive: Use Google Drive as the single entrypoint for Drive, Docs, Sheets, and Slides work.\n- docs\nSome of the tools may not have been provided to you upfront, and you should use this tool (`tool_search`) to search for the required tools. For MCP tool discovery, always use `tool_search` instead of `list_mcp_resources` or `list_mcp_resource_templates`.".to_string(),
parameters: JsonSchema::object(BTreeMap::from([
(
"limit".to_string(),
JsonSchema::number(Some(
"Maximum number of tools to return (defaults to 8)."
.to_string(),
),),
),
(
"query".to_string(),
JsonSchema::string(Some("Search query for deferred tools.".to_string()),),
),
]), Some(vec!["query".to_string()]), Some(false.into())),
}
);
}
}
@@ -0,0 +1,55 @@
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
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(Some("Local filesystem path to an image file".to_string())),
)]);
if options.can_request_original_image_detail {
properties.insert(
"detail".to_string(),
JsonSchema::string(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, Some(vec!["path".to_string()]), 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
})
}
+54
View File
@@ -0,0 +1,54 @@
use codex_protocol::config_types::WebSearchConfig;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::openai_models::WebSearchToolType;
use codex_tools::ToolSpec;
const WEB_SEARCH_TEXT_AND_IMAGE_CONTENT_TYPES: [&str; 2] = ["text", "image"];
pub struct WebSearchToolOptions<'a> {
pub web_search_mode: Option<WebSearchMode>,
pub web_search_config: Option<&'a WebSearchConfig>,
pub web_search_tool_type: WebSearchToolType,
}
pub fn create_image_generation_tool(output_format: &str) -> ToolSpec {
ToolSpec::ImageGeneration {
output_format: output_format.to_string(),
}
}
pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option<ToolSpec> {
let external_web_access = match options.web_search_mode {
Some(WebSearchMode::Cached) => Some(false),
Some(WebSearchMode::Live) => Some(true),
Some(WebSearchMode::Disabled) | None => None,
}?;
let search_content_types = match options.web_search_tool_type {
WebSearchToolType::Text => None,
WebSearchToolType::TextAndImage => Some(
WEB_SEARCH_TEXT_AND_IMAGE_CONTENT_TYPES
.into_iter()
.map(str::to_string)
.collect(),
),
};
Some(ToolSpec::WebSearch {
external_web_access: Some(external_web_access),
filters: options
.web_search_config
.and_then(|config| config.filters.clone().map(Into::into)),
user_location: options
.web_search_config
.and_then(|config| config.user_location.clone().map(Into::into)),
search_context_size: options
.web_search_config
.and_then(|config| config.search_context_size),
search_content_types,
})
}
#[cfg(test)]
#[path = "hosted_spec_tests.rs"]
mod tests;
@@ -0,0 +1,68 @@
use super::*;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchFilters;
use codex_protocol::config_types::WebSearchUserLocation;
use codex_protocol::config_types::WebSearchUserLocationType;
use codex_tools::ResponsesApiWebSearchFilters;
use codex_tools::ResponsesApiWebSearchUserLocation;
use pretty_assertions::assert_eq;
#[test]
fn image_generation_tool_matches_expected_spec() {
assert_eq!(
create_image_generation_tool("png"),
ToolSpec::ImageGeneration {
output_format: "png".to_string(),
}
);
}
#[test]
fn web_search_tool_preserves_configured_options() {
assert_eq!(
create_web_search_tool(WebSearchToolOptions {
web_search_mode: Some(WebSearchMode::Live),
web_search_config: Some(&WebSearchConfig {
filters: Some(WebSearchFilters {
allowed_domains: Some(vec!["example.com".to_string()]),
}),
user_location: Some(WebSearchUserLocation {
r#type: WebSearchUserLocationType::Approximate,
country: Some("US".to_string()),
region: None,
city: None,
timezone: Some("America/Los_Angeles".to_string()),
}),
search_context_size: Some(WebSearchContextSize::Low),
}),
web_search_tool_type: WebSearchToolType::TextAndImage,
}),
Some(ToolSpec::WebSearch {
external_web_access: Some(true),
filters: Some(ResponsesApiWebSearchFilters {
allowed_domains: Some(vec!["example.com".to_string()]),
}),
user_location: Some(ResponsesApiWebSearchUserLocation {
r#type: WebSearchUserLocationType::Approximate,
country: Some("US".to_string()),
region: None,
city: None,
timezone: Some("America/Los_Angeles".to_string()),
}),
search_context_size: Some(WebSearchContextSize::Low),
search_content_types: Some(vec!["text".to_string(), "image".to_string()]),
})
);
}
#[test]
fn web_search_tool_is_absent_when_disabled() {
assert_eq!(
create_web_search_tool(WebSearchToolOptions {
web_search_mode: Some(WebSearchMode::Disabled),
web_search_config: None,
web_search_tool_type: WebSearchToolType::Text,
}),
None
);
}
+3
View File
@@ -3,6 +3,7 @@ pub(crate) mod context;
pub(crate) mod events;
pub(crate) mod handlers;
pub(crate) mod hook_names;
pub(crate) mod hosted_spec;
pub(crate) mod network_approval;
pub(crate) mod orchestrator;
pub(crate) mod parallel;
@@ -11,6 +12,8 @@ pub(crate) mod router;
pub(crate) mod runtimes;
pub(crate) mod sandboxing;
pub(crate) mod spec;
pub(crate) mod spec_plan;
pub(crate) mod spec_plan_types;
pub(crate) mod tool_dispatch_trace;
pub(crate) mod tool_search_entry;
+7 -7
View File
@@ -5,24 +5,24 @@ use crate::tools::handlers::agent_jobs::SpawnAgentsOnCsvHandler;
use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS;
use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS;
use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS;
use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions;
use crate::tools::registry::ToolRegistryBuilder;
use crate::tools::spec_plan::build_tool_registry_plan;
use crate::tools::spec_plan_types::ToolHandlerKind;
use crate::tools::spec_plan_types::ToolNamespace;
use crate::tools::spec_plan_types::ToolRegistryPlanDeferredTool;
use crate::tools::spec_plan_types::ToolRegistryPlanMcpTool;
use crate::tools::spec_plan_types::ToolRegistryPlanParams;
use codex_mcp::ToolInfo;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_tools::AdditionalProperties;
use codex_tools::DiscoverableTool;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolHandlerKind;
use codex_tools::ToolName;
use codex_tools::ToolNamespace;
use codex_tools::ToolRegistryPlanDeferredTool;
use codex_tools::ToolRegistryPlanMcpTool;
use codex_tools::ToolRegistryPlanParams;
use codex_tools::ToolUserShellType;
use codex_tools::ToolsConfig;
use codex_tools::WaitAgentTimeoutOptions;
use codex_tools::augment_tool_spec_for_code_mode;
use codex_tools::build_tool_registry_plan;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
+635
View File
@@ -0,0 +1,635 @@
use crate::tools::code_mode::execute_spec::create_code_mode_tool;
use crate::tools::code_mode::wait_spec::create_wait_tool;
use crate::tools::handlers::agent_jobs_spec::create_report_agent_job_result_tool;
use crate::tools::handlers::agent_jobs_spec::create_spawn_agents_on_csv_tool;
use crate::tools::handlers::apply_patch_spec::create_apply_patch_freeform_tool;
use crate::tools::handlers::apply_patch_spec::create_apply_patch_json_tool;
use crate::tools::handlers::goal_spec::create_create_goal_tool;
use crate::tools::handlers::goal_spec::create_get_goal_tool;
use crate::tools::handlers::goal_spec::create_update_goal_tool;
use crate::tools::handlers::mcp_resource_spec::create_list_mcp_resource_templates_tool;
use crate::tools::handlers::mcp_resource_spec::create_list_mcp_resources_tool;
use crate::tools::handlers::mcp_resource_spec::create_read_mcp_resource_tool;
use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions;
use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v1;
use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v2;
use crate::tools::handlers::multi_agents_spec::create_followup_task_tool;
use crate::tools::handlers::multi_agents_spec::create_list_agents_tool;
use crate::tools::handlers::multi_agents_spec::create_resume_agent_tool;
use crate::tools::handlers::multi_agents_spec::create_send_input_tool_v1;
use crate::tools::handlers::multi_agents_spec::create_send_message_tool;
use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v1;
use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2;
use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v1;
use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v2;
use crate::tools::handlers::plan_spec::create_update_plan_tool;
use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_install_tool;
use crate::tools::handlers::request_user_input_spec::REQUEST_USER_INPUT_TOOL_NAME;
use crate::tools::handlers::request_user_input_spec::create_request_user_input_tool;
use crate::tools::handlers::request_user_input_spec::request_user_input_tool_description;
use crate::tools::handlers::shell_spec::CommandToolOptions;
use crate::tools::handlers::shell_spec::ShellToolOptions;
use crate::tools::handlers::shell_spec::create_exec_command_tool_with_environment_id;
use crate::tools::handlers::shell_spec::create_local_shell_tool;
use crate::tools::handlers::shell_spec::create_request_permissions_tool;
use crate::tools::handlers::shell_spec::create_shell_command_tool;
use crate::tools::handlers::shell_spec::create_shell_tool;
use crate::tools::handlers::shell_spec::create_write_stdin_tool;
use crate::tools::handlers::shell_spec::request_permissions_tool_description;
use crate::tools::handlers::test_sync_spec::create_test_sync_tool;
use crate::tools::handlers::tool_search_spec::create_tool_search_tool;
use crate::tools::handlers::view_image_spec::ViewImageToolOptions;
use crate::tools::handlers::view_image_spec::create_view_image_tool;
use crate::tools::hosted_spec::WebSearchToolOptions;
use crate::tools::hosted_spec::create_image_generation_tool;
use crate::tools::hosted_spec::create_web_search_tool;
use crate::tools::spec_plan_types::ToolHandlerKind;
use crate::tools::spec_plan_types::ToolRegistryPlan;
use crate::tools::spec_plan_types::ToolRegistryPlanParams;
use crate::tools::spec_plan_types::agent_type_description;
use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::ToolEnvironmentMode;
use codex_tools::ToolName;
use codex_tools::ToolSearchSource;
use codex_tools::ToolSearchSourceInfo;
use codex_tools::ToolSpec;
use codex_tools::ToolsConfig;
use codex_tools::coalesce_loadable_tool_specs;
use codex_tools::collect_code_mode_exec_prompt_tool_definitions;
use codex_tools::collect_request_plugin_install_entries;
use codex_tools::collect_tool_search_source_infos;
use codex_tools::default_namespace_description;
use codex_tools::dynamic_tool_to_loadable_tool_spec;
use codex_tools::mcp_tool_to_responses_api_tool;
use std::collections::BTreeMap;
pub fn build_tool_registry_plan(
config: &ToolsConfig,
params: ToolRegistryPlanParams<'_>,
) -> ToolRegistryPlan {
let mut plan = ToolRegistryPlan::new();
let exec_permission_approvals_enabled = config.exec_permission_approvals_enabled;
if config.code_mode_enabled {
let namespace_descriptions = params
.tool_namespaces
.into_iter()
.flatten()
.map(|(namespace, detail)| {
(
namespace.clone(),
codex_code_mode::ToolNamespaceDescription {
name: detail.name.clone(),
description: detail.description.clone().unwrap_or_default(),
},
)
})
.collect::<BTreeMap<_, _>>();
let nested_config = config.for_code_mode_nested_tools();
let nested_plan = build_tool_registry_plan(
&nested_config,
ToolRegistryPlanParams {
discoverable_tools: None,
..params
},
);
let mut enabled_tools = collect_code_mode_exec_prompt_tool_definitions(
nested_plan
.specs
.iter()
.map(|configured_tool| &configured_tool.spec),
);
enabled_tools
.sort_by(|left, right| compare_code_mode_tools(left, right, &namespace_descriptions));
plan.push_spec(
create_code_mode_tool(
&enabled_tools,
&namespace_descriptions,
config.code_mode_only_enabled,
config.search_tool
&& params
.deferred_mcp_tools
.is_some_and(|tools| !tools.is_empty()),
),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler(
codex_code_mode::PUBLIC_TOOL_NAME,
ToolHandlerKind::CodeModeExecute,
);
plan.push_spec(
create_wait_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler(
codex_code_mode::WAIT_TOOL_NAME,
ToolHandlerKind::CodeModeWait,
);
}
if config.environment_mode.has_environment() {
let include_environment_id =
matches!(config.environment_mode, ToolEnvironmentMode::Multiple);
match &config.shell_type {
ConfigShellToolType::Default => {
plan.push_spec(
create_shell_tool(ShellToolOptions {
exec_permission_approvals_enabled,
}),
/*supports_parallel_tool_calls*/ true,
config.code_mode_enabled,
);
}
ConfigShellToolType::Local => {
plan.push_spec(
create_local_shell_tool(),
/*supports_parallel_tool_calls*/ true,
config.code_mode_enabled,
);
}
ConfigShellToolType::UnifiedExec => {
plan.push_spec(
create_exec_command_tool_with_environment_id(
CommandToolOptions {
allow_login_shell: config.allow_login_shell,
exec_permission_approvals_enabled,
},
include_environment_id,
),
/*supports_parallel_tool_calls*/ true,
config.code_mode_enabled,
);
plan.push_spec(
create_write_stdin_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("exec_command", ToolHandlerKind::ExecCommand);
plan.register_handler("write_stdin", ToolHandlerKind::WriteStdin);
}
ConfigShellToolType::Disabled => {}
ConfigShellToolType::ShellCommand => {
plan.push_spec(
create_shell_command_tool(CommandToolOptions {
allow_login_shell: config.allow_login_shell,
exec_permission_approvals_enabled,
}),
/*supports_parallel_tool_calls*/ true,
config.code_mode_enabled,
);
}
}
}
if config.environment_mode.has_environment()
&& config.shell_type != ConfigShellToolType::Disabled
{
plan.register_handler("shell", ToolHandlerKind::Shell);
plan.register_handler("container.exec", ToolHandlerKind::ContainerExec);
plan.register_handler("local_shell", ToolHandlerKind::LocalShell);
plan.register_handler("shell_command", ToolHandlerKind::ShellCommand);
}
if params.mcp_tools.is_some() {
plan.push_spec(
create_list_mcp_resources_tool(),
/*supports_parallel_tool_calls*/ true,
config.code_mode_enabled,
);
plan.push_spec(
create_list_mcp_resource_templates_tool(),
/*supports_parallel_tool_calls*/ true,
config.code_mode_enabled,
);
plan.push_spec(
create_read_mcp_resource_tool(),
/*supports_parallel_tool_calls*/ true,
config.code_mode_enabled,
);
plan.register_handler("list_mcp_resources", ToolHandlerKind::ListMcpResources);
plan.register_handler(
"list_mcp_resource_templates",
ToolHandlerKind::ListMcpResourceTemplates,
);
plan.register_handler("read_mcp_resource", ToolHandlerKind::ReadMcpResource);
}
plan.push_spec(
create_update_plan_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("update_plan", ToolHandlerKind::Plan);
if config.goal_tools {
plan.push_spec(
create_get_goal_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("get_goal", ToolHandlerKind::GetGoal);
plan.push_spec(
create_create_goal_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("create_goal", ToolHandlerKind::CreateGoal);
plan.push_spec(
create_update_goal_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("update_goal", ToolHandlerKind::UpdateGoal);
}
plan.push_spec(
create_request_user_input_tool(request_user_input_tool_description(
&config.request_user_input_available_modes,
)),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler(
REQUEST_USER_INPUT_TOOL_NAME,
ToolHandlerKind::RequestUserInput,
);
if config.request_permissions_tool_enabled {
plan.push_spec(
create_request_permissions_tool(request_permissions_tool_description()),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("request_permissions", ToolHandlerKind::RequestPermissions);
}
let deferred_dynamic_tools = params
.dynamic_tools
.iter()
.filter(|tool| tool.defer_loading && (config.namespace_tools || tool.namespace.is_none()))
.collect::<Vec<_>>();
let deferred_mcp_tools_for_search = if config.namespace_tools {
params.deferred_mcp_tools
} else {
None
};
if config.search_tool
&& (deferred_mcp_tools_for_search.is_some() || !deferred_dynamic_tools.is_empty())
{
let mut search_source_infos = deferred_mcp_tools_for_search
.map(|deferred_mcp_tools| {
collect_tool_search_source_infos(deferred_mcp_tools.iter().map(|tool| {
ToolSearchSource {
server_name: tool.server_name,
connector_name: tool.connector_name,
description: tool.description,
}
}))
})
.unwrap_or_default();
if !deferred_dynamic_tools.is_empty() {
search_source_infos.push(ToolSearchSourceInfo {
name: "Dynamic tools".to_string(),
description: Some("Tools provided by the current Codex thread.".to_string()),
});
}
plan.push_spec(
create_tool_search_tool(&search_source_infos, TOOL_SEARCH_DEFAULT_LIMIT),
/*supports_parallel_tool_calls*/ true,
config.code_mode_enabled,
);
plan.register_handler(TOOL_SEARCH_TOOL_NAME, ToolHandlerKind::ToolSearch);
if let Some(deferred_mcp_tools) = deferred_mcp_tools_for_search {
for tool in deferred_mcp_tools {
plan.register_handler(tool.name.clone(), ToolHandlerKind::Mcp);
}
}
}
if config.tool_suggest
&& let Some(discoverable_tools) =
params.discoverable_tools.filter(|tools| !tools.is_empty())
{
plan.push_spec(
create_request_plugin_install_tool(&collect_request_plugin_install_entries(
discoverable_tools,
)),
/*supports_parallel_tool_calls*/ true,
/*code_mode_enabled*/ false,
);
plan.register_handler(
REQUEST_PLUGIN_INSTALL_TOOL_NAME,
ToolHandlerKind::RequestPluginInstall,
);
}
if config.environment_mode.has_environment()
&& let Some(apply_patch_tool_type) = &config.apply_patch_tool_type
{
match apply_patch_tool_type {
ApplyPatchToolType::Freeform => {
plan.push_spec(
create_apply_patch_freeform_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
}
ApplyPatchToolType::Function => {
plan.push_spec(
create_apply_patch_json_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
}
}
plan.register_handler("apply_patch", ToolHandlerKind::ApplyPatch);
}
if config
.experimental_supported_tools
.iter()
.any(|tool| tool == "test_sync_tool")
{
plan.push_spec(
create_test_sync_tool(),
/*supports_parallel_tool_calls*/ true,
config.code_mode_enabled,
);
plan.register_handler("test_sync_tool", ToolHandlerKind::TestSync);
}
if let Some(web_search_tool) = create_web_search_tool(WebSearchToolOptions {
web_search_mode: config.web_search_mode,
web_search_config: config.web_search_config.as_ref(),
web_search_tool_type: config.web_search_tool_type,
}) {
plan.push_spec(
web_search_tool,
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
}
if config.image_gen_tool {
plan.push_spec(
create_image_generation_tool("png"),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
}
if config.environment_mode.has_environment() {
plan.push_spec(
create_view_image_tool(ViewImageToolOptions {
can_request_original_image_detail: config.can_request_original_image_detail,
}),
/*supports_parallel_tool_calls*/ true,
config.code_mode_enabled,
);
plan.register_handler("view_image", ToolHandlerKind::ViewImage);
}
if config.collab_tools {
if config.multi_agent_v2 {
let agent_type_description =
agent_type_description(config, params.default_agent_type_description);
plan.push_spec(
create_spawn_agent_tool_v2(SpawnAgentToolOptions {
available_models: &config.available_models,
agent_type_description,
hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata,
include_usage_hint: config.spawn_agent_usage_hint,
usage_hint_text: config.spawn_agent_usage_hint_text.clone(),
max_concurrent_threads_per_session: config.max_concurrent_threads_per_session,
}),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.push_spec(
create_send_message_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.push_spec(
create_followup_task_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.push_spec(
create_wait_agent_tool_v2(params.wait_agent_timeouts),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.push_spec(
create_close_agent_tool_v2(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.push_spec(
create_list_agents_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("spawn_agent", ToolHandlerKind::SpawnAgentV2);
plan.register_handler("send_message", ToolHandlerKind::SendMessageV2);
plan.register_handler("followup_task", ToolHandlerKind::FollowupTaskV2);
plan.register_handler("wait_agent", ToolHandlerKind::WaitAgentV2);
plan.register_handler("close_agent", ToolHandlerKind::CloseAgentV2);
plan.register_handler("list_agents", ToolHandlerKind::ListAgentsV2);
} else {
let agent_type_description =
agent_type_description(config, params.default_agent_type_description);
plan.push_spec(
create_spawn_agent_tool_v1(SpawnAgentToolOptions {
available_models: &config.available_models,
agent_type_description,
hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata,
include_usage_hint: config.spawn_agent_usage_hint,
usage_hint_text: config.spawn_agent_usage_hint_text.clone(),
max_concurrent_threads_per_session: config.max_concurrent_threads_per_session,
}),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.push_spec(
create_send_input_tool_v1(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.push_spec(
create_resume_agent_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("resume_agent", ToolHandlerKind::ResumeAgentV1);
plan.push_spec(
create_wait_agent_tool_v1(params.wait_agent_timeouts),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.push_spec(
create_close_agent_tool_v1(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("spawn_agent", ToolHandlerKind::SpawnAgentV1);
plan.register_handler("send_input", ToolHandlerKind::SendInputV1);
plan.register_handler("wait_agent", ToolHandlerKind::WaitAgentV1);
plan.register_handler("close_agent", ToolHandlerKind::CloseAgentV1);
}
}
if config.agent_jobs_tools {
plan.push_spec(
create_spawn_agents_on_csv_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("spawn_agents_on_csv", ToolHandlerKind::SpawnAgentsOnCsv);
if config.agent_jobs_worker_tools {
plan.push_spec(
create_report_agent_job_result_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler(
"report_agent_job_result",
ToolHandlerKind::ReportAgentJobResult,
);
}
}
if let Some(mcp_tools) = params.mcp_tools {
let mut entries = mcp_tools.to_vec();
entries.sort_by_key(|tool| tool.name.display());
let mut namespace_entries = BTreeMap::new();
for tool in entries {
let Some(namespace) = tool.name.namespace.as_ref() else {
let tool_name = &tool.name;
tracing::error!("Skipping MCP tool `{tool_name}`: MCP tools must be namespaced");
continue;
};
namespace_entries
.entry(namespace.clone())
.or_insert_with(Vec::new)
.push(tool);
}
for (namespace, mut entries) in namespace_entries {
entries.sort_by_key(|tool| tool.name.name.clone());
let tool_namespace = params
.tool_namespaces
.and_then(|namespaces| namespaces.get(&namespace));
let description = tool_namespace
.and_then(|namespace| namespace.description.as_deref())
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
.unwrap_or_else(|| {
let namespace_name = tool_namespace
.map(|namespace| namespace.name.as_str())
.unwrap_or(namespace.as_str());
default_namespace_description(namespace_name)
});
let mut tools = Vec::new();
for tool in entries {
match mcp_tool_to_responses_api_tool(&tool.name, tool.tool) {
Ok(converted_tool) => {
tools.push(ResponsesApiNamespaceTool::Function(converted_tool));
plan.register_handler(tool.name, ToolHandlerKind::Mcp);
}
Err(error) => {
let tool_name = &tool.name;
tracing::error!(
"Failed to convert `{tool_name}` MCP tool to OpenAI tool: {error:?}"
);
}
}
}
if !tools.is_empty() {
plan.push_spec(
ToolSpec::Namespace(ResponsesApiNamespace {
name: namespace,
description,
tools,
}),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
}
}
}
let mut dynamic_tool_specs = Vec::new();
for tool in params.dynamic_tools {
match dynamic_tool_to_loadable_tool_spec(tool) {
Ok(loadable_tool) => {
let handler_name = ToolName::new(tool.namespace.clone(), tool.name.clone());
dynamic_tool_specs.push(loadable_tool);
plan.register_handler(handler_name, ToolHandlerKind::DynamicTool);
}
Err(error) => {
tracing::error!(
"Failed to convert dynamic tool {:?} to OpenAI tool: {error:?}",
tool.name
);
}
}
}
for spec in coalesce_loadable_tool_specs(dynamic_tool_specs) {
plan.push_spec(
spec.into(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
}
if !config.namespace_tools {
plan.specs
.retain(|configured_tool| !matches!(&configured_tool.spec, ToolSpec::Namespace(_)));
}
plan
}
fn compare_code_mode_tools(
left: &codex_code_mode::ToolDefinition,
right: &codex_code_mode::ToolDefinition,
namespace_descriptions: &BTreeMap<String, codex_code_mode::ToolNamespaceDescription>,
) -> std::cmp::Ordering {
let left_namespace = code_mode_namespace_name(left, namespace_descriptions);
let right_namespace = code_mode_namespace_name(right, namespace_descriptions);
left_namespace
.cmp(&right_namespace)
.then_with(|| left.tool_name.name.cmp(&right.tool_name.name))
.then_with(|| left.name.cmp(&right.name))
}
fn code_mode_namespace_name<'a>(
tool: &codex_code_mode::ToolDefinition,
namespace_descriptions: &'a BTreeMap<String, codex_code_mode::ToolNamespaceDescription>,
) -> Option<&'a str> {
tool.tool_name
.namespace
.as_ref()
.and_then(|namespace| namespace_descriptions.get(namespace))
.map(|namespace_description| namespace_description.name.as_str())
}
#[cfg(test)]
#[path = "spec_plan_tests.rs"]
mod tests;
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_tools::ConfiguredToolSpec;
use codex_tools::DiscoverableTool;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use codex_tools::ToolsConfig;
use codex_tools::augment_tool_spec_for_code_mode;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolHandlerKind {
ApplyPatch,
CloseAgentV1,
CloseAgentV2,
CodeModeExecute,
CodeModeWait,
ContainerExec,
CreateGoal,
DynamicTool,
ExecCommand,
FollowupTaskV2,
GetGoal,
ListAgentsV2,
ListMcpResourceTemplates,
ListMcpResources,
LocalShell,
Mcp,
Plan,
ReadMcpResource,
ReportAgentJobResult,
RequestPluginInstall,
RequestPermissions,
RequestUserInput,
ResumeAgentV1,
SendInputV1,
SendMessageV2,
Shell,
ShellCommand,
SpawnAgentsOnCsv,
SpawnAgentV1,
SpawnAgentV2,
TestSync,
ToolSearch,
UpdateGoal,
ViewImage,
WaitAgentV1,
WaitAgentV2,
WriteStdin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolHandlerSpec {
pub name: ToolName,
pub kind: ToolHandlerKind,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolRegistryPlan {
pub specs: Vec<ConfiguredToolSpec>,
pub handlers: Vec<ToolHandlerSpec>,
}
#[derive(Debug, Clone, Copy)]
pub struct ToolRegistryPlanParams<'a> {
pub mcp_tools: Option<&'a [ToolRegistryPlanMcpTool<'a>]>,
pub deferred_mcp_tools: Option<&'a [ToolRegistryPlanDeferredTool<'a>]>,
pub tool_namespaces: Option<&'a HashMap<String, ToolNamespace>>,
pub discoverable_tools: Option<&'a [DiscoverableTool]>,
pub dynamic_tools: &'a [DynamicToolSpec],
pub default_agent_type_description: &'a str,
pub wait_agent_timeouts: WaitAgentTimeoutOptions,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolNamespace {
pub name: String,
pub description: Option<String>,
}
/// Direct MCP tool metadata needed to expose the Responses API namespace tool
/// while registering its runtime handler with the canonical namespace/name
/// identity.
#[derive(Debug, Clone)]
pub struct ToolRegistryPlanMcpTool<'a> {
pub name: ToolName,
pub tool: &'a rmcp::model::Tool,
}
#[derive(Debug, Clone)]
pub struct ToolRegistryPlanDeferredTool<'a> {
pub name: ToolName,
pub server_name: &'a str,
pub connector_name: Option<&'a str>,
pub description: Option<&'a str>,
}
impl ToolRegistryPlan {
pub(crate) fn new() -> Self {
Self {
specs: Vec::new(),
handlers: Vec::new(),
}
}
pub(crate) fn push_spec(
&mut self,
spec: ToolSpec,
supports_parallel_tool_calls: bool,
code_mode_enabled: bool,
) {
let spec = if code_mode_enabled {
augment_tool_spec_for_code_mode(spec)
} else {
spec
};
self.specs
.push(ConfiguredToolSpec::new(spec, supports_parallel_tool_calls));
}
pub(crate) fn register_handler(&mut self, name: impl Into<ToolName>, kind: ToolHandlerKind) {
self.handlers.push(ToolHandlerSpec {
name: name.into(),
kind,
});
}
}
pub(crate) fn agent_type_description(
config: &ToolsConfig,
default_agent_type_description: &str,
) -> String {
if config.agent_type_description.is_empty() {
default_agent_type_description.to_string()
} else {
config.agent_type_description.clone()
}
}