Agent jobs (spawn_agents_on_csv) + progress UI (#10935)

## Summary
- Add agent job support: spawn a batch of sub-agents from CSV, auto-run,
auto-export, and store results in SQLite.
- Simplify workflow: remove run/resume/get-status/export tools; spawn is
deterministic and completes in one call.
- Improve exec UX: stable, single-line progress bar with ETA; suppress
sub-agent chatter in exec.

## Why
Enables map-reduce style workflows over arbitrarily large repos using
the existing Codex orchestrator. This addresses review feedback about
overly complex job controls and non-deterministic monitoring.

## Demo (progress bar)
```
./codex-rs/target/debug/codex exec \
  --enable collab \
  --enable sqlite \
  --full-auto \
  --progress-cursor \
  -c agents.max_threads=16 \
  -C /Users/daveaitel/code/codex \
  - <<'PROMPT'
Create /tmp/agent_job_progress_demo.csv with columns: path,area and 30 rows:
path = item-01..item-30, area = test.

Then call spawn_agents_on_csv with:
- csv_path: /tmp/agent_job_progress_demo.csv
- instruction: "Run `python - <<'PY'` to sleep a random 0.3–1.2s, then output JSON with keys: path, score (int). Set score = 1."
- output_csv_path: /tmp/agent_job_progress_demo_out.csv
PROMPT
```

## Review feedback addressed
- Auto-start jobs on spawn; removed run/resume/status/export tools.
- Auto-export on success.
- More descriptive tool spec + clearer prompts.
- Avoid deadlocks on spawn failure; pending/running handled safely.
- Progress bar no longer scrolls; stable single-line redraw.

## Tests
- `cd codex-rs && cargo test -p codex-exec`
- `cd codex-rs && cargo build -p codex-cli`
This commit is contained in:
daveaitel-openai
2026-02-24 21:00:19 +00:00
committed by GitHub
parent bd192b54cd
commit dcab40123f
36 changed files with 3370 additions and 50 deletions
+5 -1
View File
@@ -1,3 +1,4 @@
use crate::config::DEFAULT_AGENT_MAX_SPAWN_DEPTH;
use crate::error::CodexErr;
use crate::error::Result;
use codex_protocol::ThreadId;
@@ -30,7 +31,6 @@ struct ActiveAgents {
used_agent_nicknames: HashSet<String>,
nickname_reset_count: usize,
}
fn session_depth(session_source: &SessionSource) -> i32 {
match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth,
@@ -43,6 +43,10 @@ pub(crate) fn next_thread_spawn_depth(session_source: &SessionSource) -> i32 {
session_depth(session_source).saturating_add(1)
}
pub(crate) fn max_thread_spawn_depth(max_depth: Option<usize>) -> i32 {
let max_depth = max_depth.or(DEFAULT_AGENT_MAX_SPAWN_DEPTH).unwrap_or(1);
i32::try_from(max_depth).unwrap_or(i32::MAX)
}
pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bool {
depth > max_depth
}
+1
View File
@@ -6,5 +6,6 @@ pub(crate) mod status;
pub(crate) use codex_protocol::protocol::AgentStatus;
pub(crate) use control::AgentControl;
pub(crate) use guards::exceeds_thread_spawn_depth_limit;
pub(crate) use guards::max_thread_spawn_depth;
pub(crate) use guards::next_thread_spawn_depth;
pub(crate) use status::agent_status_from_event;
+6
View File
@@ -640,6 +640,7 @@ impl TurnContext {
model_info: &model_info,
features: &features,
web_search_mode: self.tools_config.web_search_mode,
session_source: self.session_source.clone(),
})
.with_allow_login_shell(self.tools_config.allow_login_shell)
.with_agent_roles(config.agent_roles.clone());
@@ -975,6 +976,7 @@ impl Session {
model_info: &model_info,
features: &per_turn_config.features,
web_search_mode: Some(per_turn_config.web_search_mode.value()),
session_source: session_source.clone(),
})
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
.with_agent_roles(per_turn_config.agent_roles.clone());
@@ -4592,6 +4594,7 @@ async fn spawn_review_thread(
model_info: &review_model_info,
features: &review_features,
web_search_mode: Some(review_web_search_mode),
session_source: parent_turn_context.session_source.clone(),
})
.with_allow_login_shell(config.permissions.allow_login_shell)
.with_agent_roles(config.agent_roles.clone());
@@ -9267,6 +9270,7 @@ mod tests {
})
.to_string(),
},
source: ToolCallSource::Direct,
})
.await;
@@ -9306,6 +9310,7 @@ mod tests {
})
.to_string(),
},
source: ToolCallSource::Direct,
})
.await;
@@ -9365,6 +9370,7 @@ mod tests {
})
.to_string(),
},
source: ToolCallSource::Direct,
})
.await;
+106 -1
View File
@@ -115,8 +115,35 @@ pub use codex_git::GhostSnapshotConfig;
/// the context window.
pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = Some(6);
pub(crate) const DEFAULT_AGENT_MAX_SPAWN_DEPTH: Option<usize> = Some(2);
pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1;
pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option<u64> = None;
pub const CONFIG_TOML_FILE: &str = "config.toml";
fn default_sqlite_home(sandbox_policy: &SandboxPolicy, codex_home: &Path) -> PathBuf {
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
let mut path = std::env::temp_dir();
path.push("codex-sqlite");
path
} else {
codex_home.to_path_buf()
}
}
fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let path = PathBuf::from(trimmed);
if path.is_absolute() {
Some(path)
} else {
Some(resolved_cwd.join(path))
}
}
#[cfg(test)]
pub(crate) fn test_config() -> Config {
let codex_home = tempdir().expect("create temp dir");
@@ -330,6 +357,10 @@ pub struct Config {
/// Maximum number of agent threads that can be open concurrently.
pub agent_max_threads: Option<usize>,
/// Maximum depth for thread-spawned subagents.
pub agent_max_spawn_depth: Option<usize>,
/// Maximum runtime in seconds for agent job workers before they are failed.
pub agent_job_max_runtime_seconds: Option<u64>,
/// Maximum nesting depth allowed for spawned agent threads.
pub agent_max_depth: i32,
@@ -344,6 +375,9 @@ pub struct Config {
/// overridden by the `CODEX_HOME` environment variable).
pub codex_home: PathBuf,
/// Directory where Codex stores the SQLite state DB.
pub sqlite_home: PathBuf,
/// Directory where Codex writes log files (defaults to `$CODEX_HOME/log`).
pub log_dir: PathBuf,
@@ -1108,6 +1142,11 @@ pub struct ConfigToml {
#[serde(default)]
pub history: Option<History>,
/// Directory where Codex stores the SQLite state DB.
/// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses a temp dir
/// under WorkspaceWrite sandboxing and `$CODEX_HOME` for other modes.
pub sqlite_home: Option<AbsolutePathBuf>,
/// Directory where Codex writes log files, for example `codex-tui.log`.
/// Defaults to `$CODEX_HOME/log`.
pub log_dir: Option<AbsolutePathBuf>,
@@ -1295,11 +1334,16 @@ pub struct AgentsToml {
/// When unset, no limit is enforced.
#[schemars(range(min = 1))]
pub max_threads: Option<usize>,
/// Maximum depth for thread-spawned subagents.
#[schemars(range(min = 1))]
pub max_spawn_depth: Option<usize>,
/// Maximum nesting depth allowed for spawned agent threads.
/// Root sessions start at depth 0.
#[schemars(range(min = 1))]
pub max_depth: Option<i32>,
/// Default maximum runtime in seconds for agent job workers.
#[schemars(range(min = 1))]
pub job_max_runtime_seconds: Option<u64>,
/// User-defined role declarations keyed by role name.
///
@@ -1813,6 +1857,44 @@ impl Config {
})
.transpose()?
.unwrap_or_default();
let agent_max_spawn_depth = cfg
.agents
.as_ref()
.and_then(|agents| agents.max_spawn_depth)
.or(DEFAULT_AGENT_MAX_SPAWN_DEPTH);
if agent_max_spawn_depth == Some(0) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"agents.max_spawn_depth must be at least 1",
));
}
if let Some(max_spawn_depth) = agent_max_spawn_depth
&& max_spawn_depth > i32::MAX as usize
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"agents.max_spawn_depth must fit within a 32-bit signed integer",
));
}
let agent_job_max_runtime_seconds = cfg
.agents
.as_ref()
.and_then(|agents| agents.job_max_runtime_seconds)
.or(DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS);
if agent_job_max_runtime_seconds == Some(0) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"agents.job_max_runtime_seconds must be at least 1",
));
}
if let Some(max_runtime_seconds) = agent_job_max_runtime_seconds
&& max_runtime_seconds > i64::MAX as u64
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"agents.job_max_runtime_seconds must fit within a 64-bit signed integer",
));
}
let background_terminal_max_timeout = cfg
.background_terminal_max_timeout
.unwrap_or(DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS)
@@ -1937,6 +2019,12 @@ impl Config {
p.push("log");
p
});
let sqlite_home = cfg
.sqlite_home
.as_ref()
.map(AbsolutePathBuf::to_path_buf)
.or_else(|| resolve_sqlite_home_env(&resolved_cwd))
.unwrap_or_else(|| default_sqlite_home(&sandbox_policy, &codex_home));
// Ensure that every field of ConfigRequirements is applied to the final
// Config.
@@ -2053,7 +2141,10 @@ impl Config {
agent_max_depth,
agent_roles,
memories: cfg.memories.unwrap_or_default().into(),
agent_max_spawn_depth,
agent_job_max_runtime_seconds,
codex_home,
sqlite_home,
log_dir,
config_layer_stack,
history,
@@ -4387,7 +4478,9 @@ model = "gpt-5.1-codex"
let cfg = ConfigToml {
agents: Some(AgentsToml {
max_threads: None,
max_spawn_depth: None,
max_depth: None,
job_max_runtime_seconds: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -4661,7 +4754,10 @@ model_verbosity = "high"
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_max_spawn_depth: DEFAULT_AGENT_MAX_SPAWN_DEPTH,
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
@@ -4784,7 +4880,10 @@ model_verbosity = "high"
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_max_spawn_depth: DEFAULT_AGENT_MAX_SPAWN_DEPTH,
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
@@ -4905,7 +5004,10 @@ model_verbosity = "high"
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_max_spawn_depth: DEFAULT_AGENT_MAX_SPAWN_DEPTH,
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
@@ -5012,7 +5114,10 @@ model_verbosity = "high"
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_max_spawn_depth: DEFAULT_AGENT_MAX_SPAWN_DEPTH,
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
+35 -17
View File
@@ -123,6 +123,13 @@ impl ShellSnapshot {
let path = codex_home
.join(SNAPSHOT_DIR)
.join(format!("{session_id}.{extension}"));
let nonce = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let temp_path = codex_home
.join(SNAPSHOT_DIR)
.join(format!("{session_id}.tmp-{nonce}"));
// Clean the (unlikely) leaked snapshot files.
let codex_home = codex_home.to_path_buf();
@@ -134,31 +141,42 @@ impl ShellSnapshot {
});
// Make the new snapshot.
let path = match write_shell_snapshot(shell.shell_type.clone(), &path, session_cwd).await {
Ok(path) => {
tracing::info!("Shell snapshot successfully created: {}", path.display());
path
}
Err(err) => {
tracing::warn!(
"Failed to create shell snapshot for {}: {err:?}",
shell.name()
);
return Err("write_failed");
}
};
let temp_path =
match write_shell_snapshot(shell.shell_type.clone(), &temp_path, session_cwd).await {
Ok(path) => {
tracing::info!("Shell snapshot successfully created: {}", path.display());
path
}
Err(err) => {
tracing::warn!(
"Failed to create shell snapshot for {}: {err:?}",
shell.name()
);
return Err("write_failed");
}
};
let snapshot = Self {
path,
let temp_snapshot = Self {
path: temp_path.clone(),
cwd: session_cwd.to_path_buf(),
};
if let Err(err) = validate_snapshot(shell, &snapshot.path, session_cwd).await {
if let Err(err) = validate_snapshot(shell, &temp_snapshot.path, session_cwd).await {
tracing::error!("Shell snapshot validation failed: {err:?}");
remove_snapshot_file(&temp_snapshot.path).await;
return Err("validation_failed");
}
Ok(snapshot)
if let Err(err) = fs::rename(&temp_snapshot.path, &path).await {
tracing::warn!("Failed to finalize shell snapshot: {err:?}");
remove_snapshot_file(&temp_snapshot.path).await;
return Err("write_failed");
}
Ok(Self {
path,
cwd: session_cwd.to_path_buf(),
})
}
}
+5 -5
View File
@@ -37,7 +37,7 @@ pub(crate) async fn init_if_enabled(
return None;
}
let runtime = match codex_state::StateRuntime::init(
config.codex_home.clone(),
config.sqlite_home.clone(),
config.model_provider_id.clone(),
otel.cloned(),
)
@@ -47,7 +47,7 @@ pub(crate) async fn init_if_enabled(
Err(err) => {
warn!(
"failed to initialize state runtime at {}: {err}",
config.codex_home.display()
config.sqlite_home.display()
);
if let Some(otel) = otel {
otel.counter("codex.db.init", 1, &[("status", "init_error")]);
@@ -79,20 +79,20 @@ pub(crate) async fn init_if_enabled(
/// Get the DB if the feature is enabled and the DB exists.
pub async fn get_state_db(config: &Config, otel: Option<&OtelManager>) -> Option<StateDbHandle> {
let state_path = codex_state::state_db_path(config.codex_home.as_path());
let state_path = codex_state::state_db_path(config.sqlite_home.as_path());
if !config.features.enabled(Feature::Sqlite)
|| !tokio::fs::try_exists(&state_path).await.unwrap_or(false)
{
return None;
}
let runtime = codex_state::StateRuntime::init(
config.codex_home.clone(),
config.sqlite_home.clone(),
config.model_provider_id.clone(),
otel.cloned(),
)
.await
.ok()?;
require_backfill_complete(runtime, config.codex_home.as_path()).await
require_backfill_complete(runtime, config.sqlite_home.as_path()).await
}
/// Open the state runtime when the SQLite file exists, without feature gating.
+7
View File
@@ -16,6 +16,12 @@ use tokio::sync::Mutex;
pub type SharedTurnDiffTracker = Arc<Mutex<TurnDiffTracker>>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ToolCallSource {
Direct,
JsRepl,
}
#[derive(Clone)]
pub struct ToolInvocation {
pub session: Arc<Session>,
@@ -24,6 +30,7 @@ pub struct ToolInvocation {
pub call_id: String,
pub tool_name: String,
pub payload: ToolPayload,
pub source: ToolCallSource,
}
#[derive(Clone, Debug)]
File diff suppressed because it is too large Load Diff
@@ -86,6 +86,7 @@ impl ToolHandler for ApplyPatchHandler {
call_id,
tool_name,
payload,
..
} = invocation;
let patch_input = match payload {
+1
View File
@@ -1,3 +1,4 @@
pub(crate) mod agent_jobs;
pub mod apply_patch;
mod dynamic;
mod grep_files;
@@ -1,5 +1,6 @@
use crate::agent::AgentStatus;
use crate::agent::exceeds_thread_spawn_depth_limit;
use crate::agent::max_thread_spawn_depth;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::Config;
@@ -97,6 +98,7 @@ mod spawn {
use crate::agent::role::apply_role_to_config;
use crate::agent::exceeds_thread_spawn_depth_limit;
use crate::agent::max_thread_spawn_depth;
use crate::agent::next_thread_spawn_depth;
use std::sync::Arc;
@@ -129,7 +131,8 @@ mod spawn {
let prompt = input_preview(&input_items);
let session_source = turn.session_source.clone();
let child_depth = next_thread_spawn_depth(&session_source);
if exceeds_thread_spawn_depth_limit(child_depth, turn.config.agent_max_depth) {
let max_depth = max_thread_spawn_depth(turn.config.agent_max_spawn_depth);
if exceeds_thread_spawn_depth_limit(child_depth, max_depth) {
return Err(FunctionCallError::RespondToModel(
"Agent depth limit reached. Solve the task yourself.".to_string(),
));
@@ -345,7 +348,8 @@ mod resume_agent {
.await
.unwrap_or((None, None));
let child_depth = next_thread_spawn_depth(&turn.session_source);
if exceeds_thread_spawn_depth_limit(child_depth, turn.config.agent_max_depth) {
let max_depth = max_thread_spawn_depth(turn.config.agent_max_spawn_depth);
if exceeds_thread_spawn_depth_limit(child_depth, max_depth) {
return Err(FunctionCallError::RespondToModel(
"Agent depth limit reached. Solve the task yourself.".to_string(),
));
@@ -891,7 +895,7 @@ fn input_preview(items: &[UserInput]) -> String {
parts.join("\n")
}
fn build_agent_spawn_config(
pub(crate) fn build_agent_spawn_config(
base_instructions: &BaseInstructions,
turn: &TurnContext,
child_depth: i32,
@@ -948,7 +952,8 @@ fn apply_spawn_agent_runtime_overrides(
fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) {
config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);
if exceeds_thread_spawn_depth_limit(child_depth + 1, config.agent_max_depth) {
let max_depth = max_thread_spawn_depth(config.agent_max_spawn_depth);
if exceeds_thread_spawn_depth_limit(child_depth + 1, max_depth) {
config.features.disable(Feature::Collab);
}
}
@@ -959,6 +964,7 @@ mod tests {
use crate::AuthManager;
use crate::CodexAuth;
use crate::ThreadManager;
use crate::agent::max_thread_spawn_depth;
use crate::built_in_model_providers;
use crate::codex::make_session_and_context;
use crate::config::DEFAULT_AGENT_MAX_DEPTH;
@@ -998,6 +1004,7 @@ mod tests {
call_id: "call-1".to_string(),
tool_name: tool_name.to_string(),
payload,
source: crate::tools::router::ToolCallSource::Direct,
}
}
@@ -1259,9 +1266,10 @@ mod tests {
let manager = thread_manager();
session.services.agent_control = manager.agent_control();
let max_depth = max_thread_spawn_depth(turn.config.agent_max_spawn_depth);
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: session.conversation_id,
depth: DEFAULT_AGENT_MAX_DEPTH,
depth: max_depth,
agent_nickname: None,
agent_role: None,
});
@@ -1689,9 +1697,10 @@ mod tests {
let manager = thread_manager();
session.services.agent_control = manager.agent_control();
let max_depth = max_thread_spawn_depth(turn.config.agent_max_spawn_depth);
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: session.conversation_id,
depth: DEFAULT_AGENT_MAX_DEPTH,
depth: max_depth,
agent_nickname: None,
agent_role: None,
});
@@ -176,6 +176,7 @@ impl ToolHandler for ShellHandler {
call_id,
tool_name,
payload,
..
} = invocation;
match payload {
@@ -261,6 +262,7 @@ impl ToolHandler for ShellCommandHandler {
call_id,
tool_name,
payload,
..
} = invocation;
let ToolPayload::Function { arguments } = payload else {
+23 -1
View File
@@ -8,6 +8,7 @@ use tokio::fs;
use crate::function_tool::FunctionCallError;
use crate::protocol::EventMsg;
use crate::protocol::ViewImageToolCallEvent;
use crate::tools::context::ToolCallSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
@@ -15,6 +16,7 @@ use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::local_image_content_items_with_label_number;
pub struct ViewImageHandler;
@@ -50,6 +52,7 @@ impl ToolHandler for ViewImageHandler {
turn,
payload,
call_id,
source,
..
} = invocation;
@@ -81,7 +84,26 @@ impl ToolHandler for ViewImageHandler {
}
let event_path = abs_path.clone();
let content = local_image_content_items_with_label_number(&abs_path, None)
let content = local_image_content_items_with_label_number(&abs_path, None);
if source == ToolCallSource::JsRepl
&& content
.iter()
.any(|item| matches!(item, ContentItem::InputImage { .. }))
{
let input_item = ResponseInputItem::Message {
role: "user".to_string(),
content: content.clone(),
};
if session
.inject_response_items(vec![input_item])
.await
.is_err()
{
tracing::warn!("view_image could not find an active turn to attach image input");
}
}
let content = content
.into_iter()
.map(|item| match item {
ContentItem::InputText { text } => {
+3 -6
View File
@@ -22,6 +22,8 @@ use std::collections::HashMap;
use std::sync::Arc;
use tracing::instrument;
pub use crate::tools::context::ToolCallSource;
#[derive(Clone, Debug)]
pub struct ToolCall {
pub tool_name: String,
@@ -29,12 +31,6 @@ pub struct ToolCall {
pub payload: ToolPayload,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ToolCallSource {
Direct,
JsRepl,
}
pub struct ToolRouter {
registry: ToolRegistry,
specs: Vec<ConfiguredToolSpec>,
@@ -179,6 +175,7 @@ impl ToolRouter {
call_id,
tool_name,
payload,
source,
};
match self.registry.dispatch(invocation).await {
+257 -1
View File
@@ -9,6 +9,7 @@ use crate::mcp_connection_manager::ToolInfo;
use crate::tools::handlers::PLAN_TOOL;
use crate::tools::handlers::SEARCH_TOOL_BM25_DEFAULT_LIMIT;
use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME;
use crate::tools::handlers::agent_jobs::BatchJobHandler;
use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool;
use crate::tools::handlers::apply_patch::create_apply_patch_json_tool;
use crate::tools::handlers::multi_agents::DEFAULT_WAIT_TIMEOUT_MS;
@@ -22,6 +23,8 @@ use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
@@ -53,12 +56,15 @@ pub(crate) struct ToolsConfig {
pub collab_tools: bool,
pub collaboration_modes_tools: bool,
pub experimental_supported_tools: Vec<String>,
pub agent_jobs_tools: bool,
pub agent_jobs_worker_tools: bool,
}
pub(crate) struct ToolsConfigParams<'a> {
pub(crate) model_info: &'a ModelInfo,
pub(crate) features: &'a Features,
pub(crate) web_search_mode: Option<WebSearchMode>,
pub(crate) session_source: SessionSource,
}
impl ToolsConfig {
@@ -67,14 +73,16 @@ impl ToolsConfig {
model_info,
features,
web_search_mode,
session_source,
} = params;
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
let include_js_repl = features.enabled(Feature::JsRepl);
let include_js_repl_tools_only =
include_js_repl && features.enabled(Feature::JsReplToolsOnly);
let include_collab_tools = features.enabled(Feature::Collab);
let include_collaboration_modes_tools = true;
let include_collaboration_modes_tools = features.enabled(Feature::CollaborationModes);
let include_search_tool = features.enabled(Feature::Apps);
let include_agent_jobs = include_collab_tools && features.enabled(Feature::Sqlite);
let request_permission_enabled = features.enabled(Feature::RequestPermissions);
let shell_command_backend =
if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) {
@@ -110,6 +118,13 @@ impl ToolsConfig {
}
};
let agent_jobs_worker_tools = include_agent_jobs
&& matches!(
session_source,
SessionSource::SubAgent(SubAgentSource::Other(label))
if label.starts_with("agent_job:")
);
Self {
shell_type,
shell_command_backend,
@@ -124,6 +139,8 @@ impl ToolsConfig {
collab_tools: include_collab_tools,
collaboration_modes_tools: include_collaboration_modes_tools,
experimental_supported_tools: model_info.experimental_supported_tools.clone(),
agent_jobs_tools: include_agent_jobs,
agent_jobs_worker_tools,
}
}
@@ -623,6 +640,131 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
})
}
fn create_spawn_agents_on_csv_tool() -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
"csv_path".to_string(),
JsonSchema::String {
description: Some("Path to the CSV file containing input rows.".to_string()),
},
);
properties.insert(
"instruction".to_string(),
JsonSchema::String {
description: Some(
"Instruction template to apply to each CSV row. Use {column_name} placeholders to inject values from the row."
.to_string(),
),
},
);
properties.insert(
"id_column".to_string(),
JsonSchema::String {
description: Some("Optional column name to use as stable item id.".to_string()),
},
);
properties.insert(
"output_csv_path".to_string(),
JsonSchema::String {
description: Some("Optional output CSV path for exported results.".to_string()),
},
);
properties.insert(
"max_concurrency".to_string(),
JsonSchema::Number {
description: Some(
"Maximum concurrent workers for this job. Defaults to 16 and is capped by config."
.to_string(),
),
},
);
properties.insert(
"max_workers".to_string(),
JsonSchema::Number {
description: Some(
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
),
},
);
properties.insert(
"max_runtime_seconds".to_string(),
JsonSchema::Number {
description: Some(
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
.to_string(),
),
},
);
properties.insert(
"output_schema".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
);
ToolSpec::Function(ResponsesApiTool {
name: "spawn_agents_on_csv".to_string(),
description: "Process a CSV by spawning one worker sub-agent per row. The instruction string is a template where `{column}` placeholders are replaced with row values. Each worker must call `report_agent_job_result` with a JSON object (matching `output_schema` when provided); missing reports are treated as failures. This call blocks until all rows finish and automatically exports results to `output_csv_path` (or a default path)."
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["csv_path".to_string(), "instruction".to_string()]),
additional_properties: Some(false.into()),
},
})
}
fn create_report_agent_job_result_tool() -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
"job_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the job.".to_string()),
},
);
properties.insert(
"item_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the job item.".to_string()),
},
);
properties.insert(
"result".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
);
properties.insert(
"stop".to_string(),
JsonSchema::Boolean {
description: Some(
"Optional. When true, cancels the remaining job items after this result is recorded."
.to_string(),
),
},
);
ToolSpec::Function(ResponsesApiTool {
name: "report_agent_job_result".to_string(),
description:
"Worker-only tool to report a result for an agent job item. Main agents should not call this."
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec![
"job_id".to_string(),
"item_id".to_string(),
"result".to_string(),
]),
additional_properties: Some(false.into()),
},
})
}
fn create_send_input_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
@@ -1670,6 +1812,16 @@ pub(crate) fn build_specs(
builder.register_handler("close_agent", multi_agent_handler);
}
if config.agent_jobs_tools {
let agent_jobs_handler = Arc::new(BatchJobHandler);
builder.push_spec(create_spawn_agents_on_csv_tool());
builder.register_handler("spawn_agents_on_csv", agent_jobs_handler.clone());
if config.agent_jobs_worker_tools {
builder.push_spec(create_report_agent_job_result_tool());
builder.register_handler("report_agent_job_result", agent_jobs_handler);
}
}
if let Some(mcp_tools) = mcp_tools {
let mut entries: Vec<(String, rmcp::model::Tool)> = mcp_tools.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
@@ -1870,6 +2022,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Live),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&config, None, None, &[]).build();
@@ -1928,10 +2081,42 @@ mod tests {
let mut features = Features::with_defaults();
features.enable(Feature::Collab);
features.enable(Feature::CollaborationModes);
features.enable(Feature::Sqlite);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
assert_contains_tool_names(
&tools,
&[
"spawn_agent",
"send_input",
"wait",
"close_agent",
"spawn_agents_on_csv",
],
);
}
#[test]
fn test_build_specs_agent_job_worker_tools_enabled() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::Collab);
features.enable(Feature::CollaborationModes);
features.enable(Feature::Sqlite);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::SubAgent(SubAgentSource::Other(
"agent_job:test".to_string(),
)),
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
assert_contains_tool_names(
@@ -1942,10 +2127,62 @@ mod tests {
"resume_agent",
"wait",
"close_agent",
"spawn_agents_on_csv",
"report_agent_job_result",
],
);
}
#[test]
fn request_user_input_requires_collaboration_modes_feature() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.disable(Feature::CollaborationModes);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
assert!(
!tools.iter().any(|t| t.spec.name() == "request_user_input"),
"request_user_input should be disabled when collaboration_modes feature is off"
);
features.enable(Feature::CollaborationModes);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
assert_contains_tool_names(&tools, &["request_user_input"]);
}
#[test]
fn get_memory_requires_feature_flag() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.disable(Feature::MemoryTool);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
assert!(
!tools.iter().any(|t| t.spec.name() == "get_memory"),
"get_memory should be disabled when memory_tool feature is off"
);
}
#[test]
fn js_repl_requires_feature_flag() {
let config = test_config();
@@ -1957,6 +2194,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
@@ -1982,6 +2220,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
assert_contains_tool_names(&tools, &["js_repl", "js_repl_reset"]);
@@ -2013,6 +2252,7 @@ mod tests {
model_info: &model_info,
features,
web_search_mode,
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
let tool_names = tools.iter().map(|t| t.spec.name()).collect::<Vec<_>>();
@@ -2046,6 +2286,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
@@ -2069,6 +2310,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Live),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
@@ -2092,6 +2334,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
@@ -2115,6 +2358,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build();
@@ -2314,6 +2558,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Live),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build();
@@ -2337,6 +2582,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Live),
session_source: SessionSource::Cli,
});
assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand);
@@ -2358,6 +2604,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
@@ -2382,6 +2629,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
@@ -2413,6 +2661,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Live),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(
&tools_config,
@@ -2499,6 +2748,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
// Intentionally construct a map with keys that would sort alphabetically.
@@ -2544,6 +2794,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(
@@ -2611,6 +2862,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(
@@ -2665,6 +2917,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(
@@ -2716,6 +2969,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(
@@ -2769,6 +3023,7 @@ mod tests {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(
@@ -2901,6 +3156,7 @@ Examples of valid command strings:
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(
&tools_config,