Support catalog parameter schemas for Multi-Agent V2 tools (#46505)

## What changed

Add optional JSON-encoded `parameters` to catalog tool messages and apply them to all six Multi-Agent V2 tools, including plain, namespaced, and code-mode exposure. Schema selection follows the active model, including mid-turn model changes.

Require an object schema supported by the existing `JsonSchema` subset and preserve bundled encryption annotations. Fall back to bundled parameters when overrides are missing, invalid, unsupported, or omit encrypted properties. Tool execution and argument handling remain unchanged.

## Testing

Extend integration coverage for schema overrides, fallback behavior, encryption annotations, exposure modes, and mid-turn model changes. Add a snapshot scenario exercising `list_agents` with a catalog parameter schema.

GitOrigin-RevId: 978be6d5f7e6a6865969922be5483bc697b20aca
This commit is contained in:
rhan-oai
2026-09-18 06:43:54 +00:00
committed by copyberry
parent 907b751eab
commit cc7591646e
10 changed files with 366 additions and 42 deletions

View File

@@ -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<dyn CoreToolRuntime> {
if namespace.is_none() && description_override.is_none() {
let parameters_override = parameters_override.map(|parameters| -> Result<JsonSchema, &str> {
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)) = &parameters_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<dyn CoreToolRuntime>,
namespace: Option<String>,
description_override: Option<String>,
parameters_override: Option<JsonSchema>,
}
impl ToolExecutor<ToolInvocation> for MultiAgentV2ToolOverrides {
@@ -48,10 +82,13 @@ impl ToolExecutor<ToolInvocation> 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)) => {

View File

@@ -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,
);

View File

@@ -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);

View File

@@ -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<ToolMessages>,

View File

@@ -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<()> {

View File

@@ -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: "<PROMPT_CACHE_KEY 1>"
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]
<OMITTED 155 LINES; ~4895 TOKENS; hash=fd5ef18a80f62502>
- 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] <permissions instructions>
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.
</permissions instructions>
[02] <collaboration_mode># 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 `<collaboration_mode>...<...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.</collaboration_mode> [hash=8213052bde4a62b2]
03:message/developer:
<multi_agent_role>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.
<OMITTED 17 LINES; ~246 TOKENS; hash=780031e2df907944>
- 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.</multi_agent_role> [hash=c7f52e90688299ce]
04:message/developer:
<multi_agent_mode>Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work.</multi_agent_mode> [hash=2b6e037067b4390c]
05:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="disabled"><...e="unrestricted" /></permission_profile></filesystem> [hash=d40b2f45cb028888]
</environment_context>
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"}]}

View File

@@ -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::<serde_json::Map<String, Value>>();
json!({
"model": body["model"],
"async_description": tool["description"],
"multi_agent_descriptions": descriptions,
"multi_agent_messages": multi_agent_messages,
})
})
.collect::<Vec<_>>(),
@@ -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::<serde_json::Map<String, Value>>(),
}))

View File

@@ -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()
}),

View File

@@ -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.

View File

@@ -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<String>,
/// 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<String>,
}
/// 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()
}),