diff --git a/codex-rs/core/src/tools/multi_agent_tool.rs b/codex-rs/core/src/tools/multi_agent_tool.rs index 941b9e0cf2..e2c0d0be22 100644 --- a/codex-rs/core/src/tools/multi_agent_tool.rs +++ b/codex-rs/core/src/tools/multi_agent_tool.rs @@ -1,9 +1,10 @@ -//! Applies captured Multi-Agent V2 description and namespace overrides to tool specifications. -//! Execution and parameter schemas are delegated unchanged to the underlying runtime. +//! Applies captured Multi-Agent V2 catalog overrides and namespaces to tool specifications. +//! Parameter schemas retain harness-owned encryption annotations; execution is unchanged. use crate::session::session::Session; use crate::tools::context::ToolInvocation; use crate::tools::registry::CoreToolRuntime; +use codex_tools::JsonSchema; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolExecutor; @@ -12,6 +13,7 @@ use codex_tools::ToolName; use codex_tools::ToolSearchInfo; use codex_tools::ToolSpec; use futures::future::BoxFuture; +use serde_json::Value; use std::sync::Arc; const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; @@ -20,14 +22,45 @@ pub(super) fn multi_agent_v2_handler( handler: impl CoreToolRuntime + 'static, namespace: Option<&str>, description_override: Option<&str>, + parameters_override: Option<&str>, ) -> Arc { - if namespace.is_none() && description_override.is_none() { + let parameters_override = parameters_override.map(|parameters| -> Result { + let parameters: Value = + serde_json::from_str(parameters).map_err(|_| "schema is not valid JSON")?; + if !parameters.is_object() || parameters["type"] != "object" { + return Err("schema must declare an object type"); + } + let mut parameters: JsonSchema = serde_json::from_value(parameters) + .map_err(|_| "schema uses unsupported JSON Schema structures")?; + if let ToolSpec::Function(tool) = handler.spec() + && let Some(properties) = tool.parameters.properties + { + // Argument transport requires these markers even without server encryption config. + for (name, schema) in properties { + if schema.encrypted == Some(true) { + let property = parameters + .properties + .as_mut() + .and_then(|properties| properties.get_mut(&name)) + .ok_or("schema omits an encrypted parameter")?; + property.encrypted = Some(true); + } + } + } + Ok(parameters) + }); + if let Some(Err(reason)) = ¶meters_override { + tracing::warn!(tool = %handler.tool_name(), reason, "Invalid catalog tool parameters; using bundled parameters"); + } + let parameters_override = parameters_override.and_then(Result::ok); + if namespace.is_none() && description_override.is_none() && parameters_override.is_none() { return Arc::new(handler); } Arc::new(MultiAgentV2ToolOverrides { handler: Arc::new(handler), namespace: namespace.map(str::to_owned), description_override: description_override.map(str::to_owned), + parameters_override, }) } @@ -35,6 +68,7 @@ struct MultiAgentV2ToolOverrides { handler: Arc, namespace: Option, description_override: Option, + parameters_override: Option, } impl ToolExecutor for MultiAgentV2ToolOverrides { @@ -48,10 +82,13 @@ impl ToolExecutor for MultiAgentV2ToolOverrides { fn spec(&self) -> ToolSpec { let mut spec = self.handler.spec(); - if let ToolSpec::Function(tool) = &mut spec - && let Some(description) = &self.description_override - { - tool.description.clone_from(description); + if let ToolSpec::Function(tool) = &mut spec { + if let Some(description) = &self.description_override { + tool.description.clone_from(description); + } + if let Some(parameters) = &self.parameters_override { + tool.parameters.clone_from(parameters); + } } match (&self.namespace, spec) { (Some(namespace), ToolSpec::Function(tool)) => { diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index e471bb90e7..90cfdf5c69 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -1283,6 +1283,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too // Spawn composes the selected description with runtime model and usage guidance. /*description_override*/ None, + model_messages.multi_agent_tool_parameters_override("spawn_agent"), ), exposure, ); @@ -1291,6 +1292,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too SendMessageHandlerV2, tool_namespace, model_messages.multi_agent_tool_description_override("send_message"), + model_messages.multi_agent_tool_parameters_override("send_message"), ), exposure, ); @@ -1299,6 +1301,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too FollowupTaskHandlerV2, tool_namespace, model_messages.multi_agent_tool_description_override("followup_task"), + model_messages.multi_agent_tool_parameters_override("followup_task"), ), exposure, ); @@ -1308,6 +1311,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too WaitAgentHandlerV2::new(context.wait_agent_timeouts), tool_namespace, model_messages.multi_agent_tool_description_override("wait_agent"), + model_messages.multi_agent_tool_parameters_override("wait_agent"), ), exposure, ); @@ -1317,6 +1321,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too InterruptAgentHandler, tool_namespace, model_messages.multi_agent_tool_description_override("interrupt_agent"), + model_messages.multi_agent_tool_parameters_override("interrupt_agent"), ), exposure, ); @@ -1325,6 +1330,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too ListAgentsHandlerV2, tool_namespace, model_messages.multi_agent_tool_description_override("list_agents"), + model_messages.multi_agent_tool_parameters_override("list_agents"), ), exposure, ); diff --git a/codex-rs/core/tests/suite/multi_agent_tool_descriptions.rs b/codex-rs/core/tests/suite/multi_agent_tool_descriptions.rs index 4a2484bde2..c30ea46c1b 100644 --- a/codex-rs/core/tests/suite/multi_agent_tool_descriptions.rs +++ b/codex-rs/core/tests/suite/multi_agent_tool_descriptions.rs @@ -1,4 +1,4 @@ -//! Verifies catalog descriptions reach V2 tools without changing their schemas or availability. +//! Verifies V2 catalog tool messages change only their selected description or parameter schema. use anyhow::Result; use codex_core::config::AgentRoleConfig; @@ -24,6 +24,30 @@ const TOOL_NAMES: [&str; 6] = [ "list_agents", ]; +const CATALOG_PARAMETERS: &str = r#"{ + "type": "object", + "title": "Catalog parameters", + "properties": { + "catalog_limit": {"type": "integer", "description": "Catalog limit."}, + "message": {"type": "string", "description": "Catalog message.", "encrypted": false} + }, + "required": ["catalog_limit"], + "additionalProperties": false, + "$defs": {"unused": {"type": "string"}} +}"#; + +// The existing JsonSchema subset retains supported fields and drops unknown keywords. +const EXPECTED_CATALOG_PARAMETERS: &str = r#"{ + "type": "object", + "properties": { + "catalog_limit": {"type": "integer", "description": "Catalog limit."}, + "message": {"type": "string", "description": "Catalog message.", "encrypted": false} + }, + "required": ["catalog_limit"], + "additionalProperties": false, + "$defs": {"unused": {"type": "string"}} +}"#; + #[derive(Clone, Copy)] enum Exposure { Namespaced, @@ -47,24 +71,43 @@ fn all_tool_messages(message: Value) -> Value { }) } -#[test_case(json!(null), Exposure::Namespaced; "missing_tools")] -#[test_case(json!({}), Exposure::Namespaced; "missing_multi_agent")] -#[test_case(json!({"multi_agent": null}), Exposure::Namespaced; "null_multi_agent")] -#[test_case(json!({"multi_agent": {}}), Exposure::Namespaced; "missing_tools_in_family")] -#[test_case(all_tool_messages(json!(null)), Exposure::Namespaced; "null_tool")] -#[test_case(all_tool_messages(json!({})), Exposure::Namespaced; "missing_description")] -#[test_case(all_tool_messages(json!({"description": null})), Exposure::Namespaced; "null_description")] -#[test_case(all_tool_messages(json!({"description": " Catalog TOOL_NAME description.\n{{literal_placeholder}} "})), Exposure::Namespaced; "catalog_description")] -#[test_case(all_tool_messages(json!({"description": ""})), Exposure::Namespaced; "empty_description")] -#[test_case(json!({"multi_agent": {"send_message": {"description": "Catalog send."}}}), Exposure::Namespaced; "sparse_sibling_fallback")] -#[test_case(all_tool_messages(json!({"description": "Catalog TOOL_NAME description."})), Exposure::Plain; "plain_tools")] -#[test_case(all_tool_messages(json!({"description": "Catalog TOOL_NAME description."})), Exposure::CodeMode; "code_mode_declarations")] -#[test_case(all_tool_messages(json!({"description": ""})), Exposure::CodeMode; "empty_code_mode_descriptions")] -#[test_case(all_tool_messages(json!({"description": "Catalog TOOL_NAME description."})), Exposure::V1; "v1_unchanged")] +#[test_case(json!(null), Exposure::Namespaced, None; "missing_tools")] +#[test_case(json!({}), Exposure::Namespaced, None; "missing_multi_agent")] +#[test_case(json!({"multi_agent": null}), Exposure::Namespaced, None; "null_multi_agent")] +#[test_case(json!({"multi_agent": {}}), Exposure::Namespaced, None; "missing_tools_in_family")] +#[test_case(all_tool_messages(json!(null)), Exposure::Namespaced, None; "null_tool")] +#[test_case(all_tool_messages(json!({})), Exposure::Namespaced, None; "missing_description")] +#[test_case(all_tool_messages(json!({"description": null})), Exposure::Namespaced, None; "null_description")] +#[test_case(all_tool_messages(json!({"description": " Catalog TOOL_NAME description.\n{{literal_placeholder}} "})), Exposure::Namespaced, None; "catalog_description")] +#[test_case(all_tool_messages(json!({"description": ""})), Exposure::Namespaced, None; "empty_description")] +#[test_case(json!({"multi_agent": {"send_message": {"description": "Catalog send."}}}), Exposure::Namespaced, None; "sparse_sibling_fallback")] +#[test_case(all_tool_messages(json!({"description": "Catalog TOOL_NAME description."})), Exposure::Plain, None; "plain_tools")] +#[test_case(all_tool_messages(json!({"description": "Catalog TOOL_NAME description."})), Exposure::CodeMode, None; "code_mode_declarations")] +#[test_case(all_tool_messages(json!({"description": ""})), Exposure::CodeMode, None; "empty_code_mode_descriptions")] +#[test_case(all_tool_messages(json!({"description": "Catalog TOOL_NAME description."})), Exposure::V1, None; "v1_unchanged")] +#[test_case(all_tool_messages(json!({"parameters": CATALOG_PARAMETERS})), Exposure::Namespaced, Some(EXPECTED_CATALOG_PARAMETERS); "catalog_parameters")] +#[test_case(all_tool_messages(json!({"parameters": CATALOG_PARAMETERS})), Exposure::Plain, Some(EXPECTED_CATALOG_PARAMETERS); "plain_parameters")] +#[test_case(all_tool_messages(json!({"parameters": CATALOG_PARAMETERS})), Exposure::CodeMode, Some(EXPECTED_CATALOG_PARAMETERS); "code_mode_parameters")] +#[test_case(all_tool_messages(json!({"parameters": CATALOG_PARAMETERS})), Exposure::V1, None; "v1_parameters_unchanged")] +#[test_case(json!({"multi_agent": { + "spawn_agent": {"parameters": null}, + "send_message": {"parameters": "{"}, + "followup_task": {"parameters": r#"{"type":"object","properties":{"message":true}}"#}, + "wait_agent": {"parameters": r#"{"type":["object"]}"#}, + "interrupt_agent": {"parameters": r#"[null,"object",null,null,null,null,null,{"message":{"type":"string"}},null,null,null,null,null,null,null]"#}, + "list_agents": {"parameters": "{}"} +}}), Exposure::Namespaced, None; "missing_or_invalid_parameters_fall_back")] +#[test_case(json!({"multi_agent": { + "spawn_agent": {"parameters": r#"{"type":"object"}"#}, + "send_message": {"parameters": r#"{"type":"object"}"#}, + "followup_task": {"parameters": r#"{"type":"object"}"#} +}}), Exposure::Namespaced, None; "missing_encrypted_parameters_fall_back")] +#[test_case(json!({"multi_agent": {"send_message": {"parameters": CATALOG_PARAMETERS}}}), Exposure::Namespaced, Some(EXPECTED_CATALOG_PARAMETERS); "sparse_parameters")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn multi_agent_catalog_descriptions_preserve_outbound_schema( +async fn multi_agent_catalog_messages_change_only_selected_tool_fields( tool_messages: Value, exposure: Exposure, + expected_parameters: Option<&str>, ) -> Result<()> { skip_if_no_network!(Ok(())); @@ -201,6 +244,23 @@ async fn multi_agent_catalog_descriptions_preserve_outbound_schema( }; expected_tool["description"] = json!(replacement); } + if let Some(parameters) = expected_parameters + && tool_messages["multi_agent"][name]["parameters"].is_string() + { + expected_tool["parameters"] = serde_json::from_str(parameters)?; + if matches!(name, "spawn_agent" | "send_message" | "followup_task") { + expected_tool["parameters"]["properties"]["message"]["encrypted"] = json!(true); + } + if matches!(exposure, Exposure::CodeMode) { + let description = expected_tool["description"].as_str().expect("description"); + let (prefix, signature) = + description.rsplit_once("(args: ").expect("arguments"); + let (_, result) = signature.split_once("): Promise").expect("result type"); + expected_tool["description"] = json!(format!( + "{prefix}(args: {{\n // Catalog limit.\n catalog_limit: number;\n // Catalog message.\n message?: string;\n}}): Promise{result}" + )); + } + } } } assert_eq!(actual, expected); diff --git a/codex-rs/core/tests/suite/request_user_input_async.rs b/codex-rs/core/tests/suite/request_user_input_async.rs index 5c4eaccfe9..7ffb260371 100644 --- a/codex-rs/core/tests/suite/request_user_input_async.rs +++ b/codex-rs/core/tests/suite/request_user_input_async.rs @@ -75,6 +75,7 @@ async fn persistent_async_message_guidance_follows_tool_availability( messages.tools = Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some("Catalog async message description.".to_string()), + ..Default::default() }), ..Default::default() }); @@ -248,6 +249,7 @@ async fn freeform_async_message_emits_an_item_without_ending_the_turn( .tools = Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some("Questions only.".to_string()), + ..Default::default() }), ..Default::default() }); @@ -344,8 +346,8 @@ async fn freeform_async_message_emits_an_item_without_ending_the_turn( #[test_case(None, "request_user_input_async"; "current_catalog_name")] #[test_case(Some(ToolMessages::default()), "send_user_message_async"; "missing_tool")] #[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage::default()), ..Default::default() }), "send_user_message_async"; "missing_description")] -#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some("Catalog async message description.".to_string()) }), ..Default::default() }), "send_user_message_async"; "catalog_description")] -#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some(String::new()) }), ..Default::default() }), "send_user_message_async"; "empty_description")] +#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some("Catalog async message description.".to_string()), ..Default::default() }), ..Default::default() }), "send_user_message_async"; "catalog_description")] +#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some(String::new()), ..Default::default() }), ..Default::default() }), "send_user_message_async"; "empty_description")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn request_user_input_async_emits_item_and_does_not_end_the_turn( tool_messages: Option, diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index e964ec1915..caa6788c0c 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -528,6 +528,69 @@ async fn astra_omits_disabled_executor_skills_from_model_context() -> Result<()> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_agent_catalog_parameters() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let test = test_codex() + .with_config(|config| { + configure_scenario_catalog(config); + config.workspace_roots = vec![config.cwd.clone()]; + }) + .with_model_info_override("gpt-6-astra", |model| { + model.model_messages.as_mut().expect("model messages").tools = Some( + serde_json::from_value(json!({"multi_agent": {"list_agents": { + "parameters": json!({ + "type": "object", + "properties": {"path_prefix": { + "type": "string", + "description": "Inspect agents within this task path.", + "minLength": 1, + "maxLength": 128, + }}, + "required": ["path_prefix"], + "additionalProperties": false, + }).to_string(), + }}})) + .expect("catalog tool messages"), + ); + }) + .build_with_auto_env(&server) + .await?; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("agents-response"), + ev_function_call_with_namespace( + "agents-call", + "collaboration", + "list_agents", + r#"{"path_prefix":"/root"}"#, + ), + ev_completed("agents-response"), + ]), + sse(vec![ + ev_assistant_message("final", "Only the root agent is working on this task."), + ev_completed("final-response"), + ]), + ], + ) + .await; + test.submit_turn("Check which agents are working under /root before delegating more work.") + .await?; + insta::assert_snapshot!( + "multi_agent_catalog_parameters", + context_snapshot::format_request_history_snapshot( + "Astra calls list_agents using the selected catalog parameter schema.", + &mock.requests(), + &ContextSnapshotOptions::default().include_request_settings(), + ) + ); + Ok(()) +} + #[cfg_attr(windows, ignore = "the fixture uses a Unix shell command")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn astra_settings_release_check_with_direct_and_code_mode_tools() -> Result<()> { diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__multi_agent_catalog_parameters.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__multi_agent_catalog_parameters.snap new file mode 100644 index 0000000000..985f93bbfe --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__multi_agent_catalog_parameters.snap @@ -0,0 +1,108 @@ +--- +source: core/tests/suite/scenarios.rs +expression: "context_snapshot::format_request_history_snapshot(\"Astra calls list_agents using the selected catalog parameter schema.\",\n&mock.requests(),\n&ContextSnapshotOptions::default().include_request_settings(),)" +--- +Scenario: Astra calls list_agents using the selected catalog parameter schema. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "gpt-6-astra" + parallel_tool_calls: false + prompt_cache_key: "" + reasoning: {"context":"all_turns","effort":"low"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" +-- request 1 (turn) -- +00:additional_tools/developer (3; hash=9d21738c014fb8eb): + - namespace/functions; hash=e7db0a45c4ec3157 + - custom/exec + - function/wait + - function/request_user_input + - function/request_user_input_async + - namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317 + - function/sleep + - namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2943c667577fdb8b + - function/followup_task + - function/interrupt_agent + - function/list_agents + - function/send_message + - function/spawn_agent + - function/wait_agent +01:message/developer: + You are Codex, an agent based on GPT-6. You and the user share one workspace, and your job is to collabo...them until their intended goal is completely handled. [hash=efa50f074efa5803] + + # When to ask the user for permission + + Use your best judgement given task context for when you really need user permission, like a competent co...ork without ending the turn to clarify with the user. [hash=ca7367e658b51388] + + User authorization and preferences persist across turns. Do not request permission again when the user h... any guidelines provided in skills or external files. [hash=911f91c92c72882c] + + + + - Skill naming: If a plugin contributes skills, those skill entries are prefixed with plugin_name: in the Skills list. + - MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as mcp__server__tool; use tool provenance to tell which plugin they come from. + - Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn. + - Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task. + - Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associa..., MCP tools, and apps exposed elsewhere in this turn. [hash=620459a4e7b590f0] + - Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for t..., say so briefly and continue with the best fallback. [hash=746b0469a8f6cb3e] + +02:message/developer[2]: + [01] + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`...ll commands are permitted. Network access is enabled. [hash=c134061cebb94a1b] + Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected. + + [02] # Collaboration Mode: Default + + You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. + + Your active mode changes only when new developer instructions with a different `...<...by themselves. Known mode names are Default and Plan. [hash=e75f6b12fc52d812] + + ## request_user_input availability + + Use the `request_user_input` tool only when it is listed in the available tools for this turn. + + In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. + + Use the `request_user_input` tool only for optional questions where the answer would materially improve the quality of the work. + + If `request_user_input` returns no answers, continue with best judgment instead of asking again or treating the turn as blocked. + + Never use the `request_user_input` tool for permission requests or permission-related escalations. + + If explicit user input is required for another reason before progress can safely continue, do not use th... as a textual assistant message. [hash=8213052bde4a62b2] +03:message/developer: + You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals. + + At the start of your turn, you are the active agent. + You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. + All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools. + + You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an...message to a running agent without triggering a turn. [hash=7d40ff8b58977202] + `send_message` calls may be read by a human, so ensure they are legible. Always put proper spaces between words and/or numbers. + + - All agents use the same current working directory. + - As a result, edits made by one agent are immediately visible to all other agents. + + When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling. + + There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you. + + Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d...ne"` or a positive integer string. [hash=c7f52e90688299ce] +04:message/developer: + Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work. [hash=2b6e037067b4390c] +05:message/user: + + + + + + <...e="unrestricted" /> [hash=d40b2f45cb028888] + +06:message/user: + Check which agents are working under /root before delegating more work. +-- request 2 (turn) -- +07:function_call/collaboration.list_agents:{"path_prefix":"/root"} +08:function_call_output:{"agents":[{"agent_name":"/root","agent_status":"running"}]} diff --git a/codex-rs/core/tests/suite/step_settings.rs b/codex-rs/core/tests/suite/step_settings.rs index 78dd10e39a..80d9278d13 100644 --- a/codex-rs/core/tests/suite/step_settings.rs +++ b/codex-rs/core/tests/suite/step_settings.rs @@ -2226,7 +2226,7 @@ async fn model_activation_uses_destination_metadata_defaults( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> { +async fn tool_messages_follow_mid_turn_model_changes() -> Result<()> { skip_if_no_network!(Ok(())); const MULTI_AGENT_TOOLS: [&str; 6] = [ @@ -2238,6 +2238,17 @@ async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> { "list_agents", ]; + let parameters = |model: &str| { + json!({ + "type": "object", + "properties": { + "target": {"type": "string", "description": format!("Agent on {model}.")}, + "message": {"type": "string", "encrypted": true}, + }, + "required": ["target"], + "additionalProperties": false, + }) + }; let server = start_mock_server().await; let response_mock = mount_sse_sequence( &server, @@ -2248,7 +2259,7 @@ async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> { ) .await; let test = step_settings_test() - .with_config(|config| { + .with_config(move |config| { config .features .enable(Feature::MultiAgentV2) @@ -2263,9 +2274,10 @@ async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> { model .experimental_supported_tools .push("send_user_message_async".to_string()); - let description = |name| { + let tool_message = |name| { Some(ToolMessage { description: Some(format!("{name} description for {}.", model.slug)), + parameters: Some(parameters(&model.slug).to_string()), }) }; model @@ -2273,14 +2285,17 @@ async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> { .as_mut() .expect("model instruction metadata") .tools = Some(ToolMessages { - send_user_message_async: description("Async message"), + send_user_message_async: Some(ToolMessage { + description: Some(format!("Async message description for {}.", model.slug)), + ..Default::default() + }), multi_agent: Some(MultiAgentToolMessages { - spawn_agent: description("spawn_agent"), - send_message: description("send_message"), - followup_task: description("followup_task"), - wait_agent: description("wait_agent"), - interrupt_agent: description("interrupt_agent"), - list_agents: description("list_agents"), + spawn_agent: tool_message("spawn_agent"), + send_message: tool_message("send_message"), + followup_task: tool_message("followup_task"), + wait_agent: tool_message("wait_agent"), + interrupt_agent: tool_message("interrupt_agent"), + list_agents: tool_message("list_agents"), }), }); } @@ -2317,14 +2332,17 @@ async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> { .iter() .find(|tool| tool["name"] == "request_user_input_async") .expect("async message tool"); - let descriptions = MULTI_AGENT_TOOLS.map(|name| { + let multi_agent_messages = MULTI_AGENT_TOOLS.map(|name| { let tool = namespace_child_tool(&body, "collaboration", name).expect(name); - (name.to_string(), json!(tool["description"].as_str().expect("tool description").trim())) + (name.to_string(), json!({ + "description": tool["description"].as_str().expect("tool description").trim(), + "parameters": tool["parameters"], + })) }).into_iter().collect::>(); json!({ "model": body["model"], "async_description": tool["description"], - "multi_agent_descriptions": descriptions, + "multi_agent_messages": multi_agent_messages, }) }) .collect::>(), @@ -2332,8 +2350,11 @@ async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> { .map(|model| json!({ "model": model, "async_description": format!("Async message description for {model}."), - "multi_agent_descriptions": MULTI_AGENT_TOOLS - .map(|name| (name.to_string(), json!(format!("{name} description for {model}.")))) + "multi_agent_messages": MULTI_AGENT_TOOLS + .map(|name| (name.to_string(), json!({ + "description": format!("{name} description for {model}."), + "parameters": parameters(model), + }))) .into_iter() .collect::>(), })) diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index b8734c4c72..7cb82c7ab9 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -86,10 +86,12 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { tools: Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some(async_message_description.to_string()), + ..Default::default() }), multi_agent: Some(MultiAgentToolMessages { spawn_agent: Some(ToolMessage { description: Some("Catalog spawn description.".to_string()), + ..Default::default() }), ..Default::default() }), @@ -157,10 +159,12 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() { tools: Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some(String::new()), + ..Default::default() }), multi_agent: Some(MultiAgentToolMessages { spawn_agent: Some(ToolMessage { description: Some(String::new()), + ..Default::default() }), ..Default::default() }), diff --git a/codex-rs/prompts/src/model_messages.rs b/codex-rs/prompts/src/model_messages.rs index afcdb4cc04..e774d1264d 100644 --- a/codex-rs/prompts/src/model_messages.rs +++ b/codex-rs/prompts/src/model_messages.rs @@ -6,6 +6,7 @@ use codex_protocol::openai_models::ConfirmationPolicies; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelMessages; +use codex_protocol::openai_models::ToolMessage; use permissions::ResolvedApprovalMessages; use permissions::ResolvedPermissionMessages; @@ -154,6 +155,15 @@ impl<'a> ResolvedModelMessages<'a> { /// Selects a V2 tool's static description by its name, independently of its runtime namespace. /// Missing text retains the tool's bundled description; an empty string replaces it. pub fn multi_agent_tool_description_override(&self, tool_name: &str) -> Option<&'a str> { + self.multi_agent_tool(tool_name)?.description.as_deref() + } + + /// Selects a V2 tool's complete parameter schema; parsing belongs to the tool consumer. + pub fn multi_agent_tool_parameters_override(&self, tool_name: &str) -> Option<&'a str> { + self.multi_agent_tool(tool_name)?.parameters.as_deref() + } + + fn multi_agent_tool(self, tool_name: &str) -> Option<&'a ToolMessage> { let tools = self .catalog_messages .and_then(|messages| messages.tools.as_ref()) @@ -167,7 +177,7 @@ impl<'a> ResolvedModelMessages<'a> { "list_agents" => &tools.list_agents, _ => return None, }; - tool.as_ref()?.description.as_deref() + tool.as_ref() } /// Resolves persistent-mode instructions without deciding whether the mode is active. diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 195de60c65..f52a4e601b 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -587,9 +587,17 @@ pub struct ToolMessage { /// text without disabling the tool. Tool-owned runtime guidance is retained. #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, + /// Complete JSON Schema encoded as a string. Consumed by Multi-Agent V2 tools only. + /// Uses the harness's supported schema subset; unrecognized keywords are ignored. + /// Missing, null, invalid or unsupported structures, or a root without `type: "object"` + /// retains the harness parameters. Schema semantics must remain API-compatible. + /// Overrides must declare harness-encrypted properties so their annotations can be retained. + /// Argument handling is unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parameters: Option, } -/// Model-owned descriptions for Multi-Agent V2 tools, independent of their runtime namespace. +/// Model-owned descriptions and parameters for Multi-Agent V2 tools, independent of their namespace. #[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct MultiAgentToolMessages { /// Replaces the static description. Missing or null uses the bundled text; an empty string @@ -1023,12 +1031,14 @@ mod tests { serde_json::json!({"tools": {"send_user_message_async": {"description": ""}}}), Some(Some(ToolMessage { description: Some(String::new()), + ..Default::default() })), ), ( serde_json::json!({"tools": {"send_user_message_async": {"description": "Catalog description"}}}), Some(Some(ToolMessage { description: Some("Catalog description".to_string()), + ..Default::default() })), ), ] { @@ -1392,10 +1402,12 @@ mod tests { tools: Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some("Catalog description".to_string()), + ..Default::default() }), multi_agent: Some(MultiAgentToolMessages { spawn_agent: Some(ToolMessage { description: Some("Catalog spawn description".to_string()), + ..Default::default() }), ..Default::default() }), @@ -1470,6 +1482,7 @@ mod tests { tools: Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some(String::new()), + ..Default::default() }), ..Default::default() }),