diff --git a/codex-rs/core/src/mcp_tool_call/conversation_history.rs b/codex-rs/core/src/mcp_tool_call/conversation_history.rs index a3aa36ee66..7a68a95698 100644 --- a/codex-rs/core/src/mcp_tool_call/conversation_history.rs +++ b/codex-rs/core/src/mcp_tool_call/conversation_history.rs @@ -94,6 +94,10 @@ impl<'call> ToolExecutor> for HistoryTool { ToolName::namespaced("user_message", &self.function.name) } + fn supports_parallel_tool_calls(&self) -> bool { + true + } + fn spec(&self) -> ToolSpec { ToolSpec::Namespace(ResponsesApiNamespace { name: "user_message".to_owned(), diff --git a/codex-rs/core/src/tools/handlers/current_time.rs b/codex-rs/core/src/tools/handlers/current_time.rs index 527701b963..c09a9deab3 100644 --- a/codex-rs/core/src/tools/handlers/current_time.rs +++ b/codex-rs/core/src/tools/handlers/current_time.rs @@ -53,6 +53,10 @@ impl ToolExecutor for CurrentTimeHandler { ToolName::namespaced(NAMESPACE, TOOL_NAME) } + fn supports_parallel_tool_calls(&self) -> bool { + true + } + fn spec(&self) -> ToolSpec { ToolSpec::Namespace(ResponsesApiNamespace { name: NAMESPACE.to_string(), diff --git a/codex-rs/core/src/tools/handlers/get_context_remaining.rs b/codex-rs/core/src/tools/handlers/get_context_remaining.rs index 3eecdc54f7..23c3a60868 100644 --- a/codex-rs/core/src/tools/handlers/get_context_remaining.rs +++ b/codex-rs/core/src/tools/handlers/get_context_remaining.rs @@ -63,6 +63,10 @@ impl ToolExecutor for GetContextRemainingHandler { ToolName::plain(GET_CONTEXT_REMAINING_TOOL_NAME) } + fn supports_parallel_tool_calls(&self) -> bool { + true + } + fn spec(&self) -> ToolSpec { create_get_context_remaining_tool() } diff --git a/codex-rs/core/src/tools/handlers/list_available_plugins_to_install.rs b/codex-rs/core/src/tools/handlers/list_available_plugins_to_install.rs index 1b001f7c45..0860df56bf 100644 --- a/codex-rs/core/src/tools/handlers/list_available_plugins_to_install.rs +++ b/codex-rs/core/src/tools/handlers/list_available_plugins_to_install.rs @@ -64,7 +64,7 @@ impl ToolExecutor for ListAvailablePluginsToInstallHandler { } fn supports_parallel_tool_calls(&self) -> bool { - false + true } fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a> @@ -118,13 +118,6 @@ mod tests { use codex_tools::DiscoverableToolType; use pretty_assertions::assert_eq; - #[test] - fn list_tool_does_not_support_parallel_calls() { - assert!( - !ListAvailablePluginsToInstallHandler::new(Vec::new()).supports_parallel_tool_calls() - ); - } - #[test] fn result_truncates_candidate_descriptions() { let handler = ListAvailablePluginsToInstallHandler::new(vec![ diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs index 9e0eaa74c0..b340a1fe49 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs @@ -10,6 +10,10 @@ impl ToolExecutor for Handler { ToolName::plain("list_agents") } + fn supports_parallel_tool_calls(&self) -> bool { + true + } + fn spec(&self) -> ToolSpec { create_list_agents_tool() } diff --git a/codex-rs/core/tests/suite/skills_extension/cloud_skill_tests.rs b/codex-rs/core/tests/suite/skills_extension/cloud_skill_tests.rs index a368661d5a..aa4f6d5aaa 100644 --- a/codex-rs/core/tests/suite/skills_extension/cloud_skill_tests.rs +++ b/codex-rs/core/tests/suite/skills_extension/cloud_skill_tests.rs @@ -11,6 +11,7 @@ struct FakeCloudSkillProvider { catalog: SkillCatalog, resources: std::collections::HashMap, reads: Mutex>, + read_barrier: Option, } impl SkillProvider for FakeCloudSkillProvider { @@ -42,6 +43,11 @@ impl SkillProvider for FakeCloudSkillProvider { .get(request.resource.as_str()) .cloned() .ok_or_else(|| SkillProviderError::new("unknown fake skill resource"))?; + if let Some(barrier) = &self.read_barrier { + tokio::time::timeout(Duration::from_secs(/*secs*/ 10), barrier.wait()) + .await + .map_err(|_| SkillProviderError::new("skill reads did not overlap"))?; + } Ok(SkillReadResult { resource: request.resource, contents, @@ -145,6 +151,7 @@ text({ names: result.skills.map(skill => skill.name), warnings: result.warnings, warnings: Vec::new(), }, reads: Mutex::default(), + read_barrier: None, resources: std::collections::HashMap::from([ ( MAIN_RESOURCE.to_string(), @@ -493,6 +500,7 @@ async fn production_turn_aliases_discovered_singleton_cloud_root() -> Result<()> SKILL_BODY.to_string(), )]), reads: Mutex::default(), + read_barrier: None, })), |config: &Config| SkillsExtensionConfig { include_instructions: config.include_skill_instructions, @@ -549,6 +557,99 @@ async fn production_turn_aliases_discovered_singleton_cloud_root() -> Result<()> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cloud_skill_reads_run_in_parallel() -> Result<()> { + skip_if_no_network!(Ok(())); + + const PACKAGE: &str = "skill://demo/parallel"; + const READS: [(&str, &str, &str); 2] = [ + ( + "read-main", + "skill://demo/parallel/SKILL.md", + "Main instructions.", + ), + ( + "read-reference", + "skill://demo/parallel/references/guide.md", + "Reference instructions.", + ), + ]; + let server = responses::start_mock_server().await; + let mut extensions = ExtensionRegistryBuilder::new(); + install_with_providers( + &mut extensions, + SkillProviders::new().with_cloud_provider(Arc::new(FakeCloudSkillProvider { + catalog: SkillCatalog { + entries: vec![SkillCatalogEntry::new( + SkillPackageId(PACKAGE.to_string()), + SkillAuthority::new(SkillSourceKind::Cloud, CODEX_APPS_MCP_SERVER_NAME), + "demo:parallel", + "Instructions and a reference.", + SkillResourceId::new(READS[0].1), + )], + warnings: Vec::new(), + }, + resources: READS + .iter() + .map(|(_, resource, contents)| (resource.to_string(), contents.to_string())) + .collect(), + reads: Mutex::default(), + // An exclusive scheduler lock prevents the second read from reaching this barrier. + read_barrier: Some(tokio::sync::Barrier::new(/*n*/ 2)), + })), + |config: &Config| SkillsExtensionConfig { + include_instructions: config.include_skill_instructions, + max_context_tokens: config.skill_max_context_tokens, + bundled_skills_enabled: false, + cloud_skill_enabled: true, + shadow_selection_enabled: false, + }, + ); + let chatgpt_base_url = server.uri(); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_exec_server_url("none") + .with_extensions(Arc::new(extensions.build())) + .with_config(move |config| { + config.chatgpt_base_url = chatgpt_base_url; + config.include_skill_instructions = true; + config.cloud_skill_enabled = true; + }); + let test = builder.build_with_auto_env(&server).await?; + let mut calls = vec![ev_response_created("read-both")]; + calls.extend(READS.map(|(call_id, resource, _)| { + responses::ev_function_call_with_namespace( + call_id, + "skills", + "read", + &json!({"package": PACKAGE, "resource": resource}).to_string(), + ) + })); + calls.push(ev_completed("read-both")); + let response = responses::mount_sse_sequence( + &server, + vec![ + sse(calls), + sse(vec![ev_response_created("done"), ev_completed("done")]), + ], + ) + .await; + test.submit_turn("Read both resources.").await?; + + let requests = response.requests(); + assert_eq!(requests.len(), 2); + for (call_id, resource, contents) in READS { + let output = requests[1] + .function_call_output_text(call_id) + .expect("each concurrent skill read should return its resource"); + assert_eq!( + serde_json::from_str::(&output)?, + json!({"resource": resource, "contents": contents, "next_cursor": null}) + ); + } + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cloud_skill_can_read_referenced_resource_without_an_executor() -> Result<()> { skip_if_no_network!(Ok(())); @@ -612,6 +713,7 @@ async fn cloud_skill_can_read_referenced_resource_without_an_executor() -> Resul ), ]), reads: Mutex::default(), + read_barrier: None, }); let mut extensions = ExtensionRegistryBuilder::new(); install_with_providers( diff --git a/codex-rs/core/tests/suite/skills_extension/yielded_skill_tests.rs b/codex-rs/core/tests/suite/skills_extension/yielded_skill_tests.rs index dd071818db..d207a9ca6a 100644 --- a/codex-rs/core/tests/suite/skills_extension/yielded_skill_tests.rs +++ b/codex-rs/core/tests/suite/skills_extension/yielded_skill_tests.rs @@ -31,6 +31,7 @@ async fn yielded_skill_read_keeps_originating_turn_metadata() -> Result<()> { "Yielded skill instructions.".to_string(), )]), reads: Mutex::default(), + read_barrier: None, })), |config: &Config| SkillsExtensionConfig { include_instructions: config.include_skill_instructions, diff --git a/codex-rs/ext/goal/src/tool.rs b/codex-rs/ext/goal/src/tool.rs index ff504060ee..a2b7006f6e 100644 --- a/codex-rs/ext/goal/src/tool.rs +++ b/codex-rs/ext/goal/src/tool.rs @@ -149,6 +149,10 @@ impl<'call> ToolExecutor> for GoalToolExecutor { }) } + fn supports_parallel_tool_calls(&self) -> bool { + matches!(self.kind, GoalToolKind::Get) + } + fn spec(&self) -> ToolSpec { match self.kind { GoalToolKind::Get => create_get_goal_tool(), diff --git a/codex-rs/ext/memories/src/tools/list.rs b/codex-rs/ext/memories/src/tools/list.rs index a14be8e8be..375a6c0f66 100644 --- a/codex-rs/ext/memories/src/tools/list.rs +++ b/codex-rs/ext/memories/src/tools/list.rs @@ -47,6 +47,10 @@ where memory_tool_name(LIST_TOOL_NAME) } + fn supports_parallel_tool_calls(&self) -> bool { + true + } + fn spec(&self) -> ToolSpec { memory_function_tool::( LIST_TOOL_NAME, diff --git a/codex-rs/ext/memories/src/tools/read.rs b/codex-rs/ext/memories/src/tools/read.rs index 78a9eac836..152cd64777 100644 --- a/codex-rs/ext/memories/src/tools/read.rs +++ b/codex-rs/ext/memories/src/tools/read.rs @@ -46,6 +46,10 @@ where memory_tool_name(READ_TOOL_NAME) } + fn supports_parallel_tool_calls(&self) -> bool { + true + } + fn spec(&self) -> ToolSpec { memory_function_tool::( READ_TOOL_NAME, diff --git a/codex-rs/ext/memories/src/tools/search.rs b/codex-rs/ext/memories/src/tools/search.rs index ca79bfa2c5..5f4f49ce9f 100644 --- a/codex-rs/ext/memories/src/tools/search.rs +++ b/codex-rs/ext/memories/src/tools/search.rs @@ -55,6 +55,10 @@ where memory_tool_name(SEARCH_TOOL_NAME) } + fn supports_parallel_tool_calls(&self) -> bool { + true + } + fn spec(&self) -> ToolSpec { memory_function_tool::( SEARCH_TOOL_NAME, diff --git a/codex-rs/ext/skills/src/tools/list.rs b/codex-rs/ext/skills/src/tools/list.rs index a71f46e053..7d7c64c6d5 100644 --- a/codex-rs/ext/skills/src/tools/list.rs +++ b/codex-rs/ext/skills/src/tools/list.rs @@ -68,6 +68,10 @@ impl<'call> ToolExecutor> for ListTool { skill_tool_name(TOOL_NAME) } + fn supports_parallel_tool_calls(&self) -> bool { + true + } + fn spec(&self) -> ToolSpec { skill_function_tool::( TOOL_NAME, diff --git a/codex-rs/ext/skills/src/tools/read.rs b/codex-rs/ext/skills/src/tools/read.rs index 012cc8e8a7..65605ae52e 100644 --- a/codex-rs/ext/skills/src/tools/read.rs +++ b/codex-rs/ext/skills/src/tools/read.rs @@ -61,6 +61,10 @@ impl<'call> ToolExecutor> for ReadTool { skill_tool_name(TOOL_NAME) } + fn supports_parallel_tool_calls(&self) -> bool { + true + } + fn spec(&self) -> ToolSpec { skill_function_tool::( TOOL_NAME,