mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
Allow model catalogs to override the V2 spawn_agent description (#46123)
## What changed Read the static V2 `spawn_agent` description from `model_messages.tools.multi_agent.spawn_agent.description`, independently of the runtime tool namespace. Missing or null values retain the bundled description; an empty string suppresses it. Preserve generated model guidance, local usage hints, and tool parameters when applying an override. Resolve the description from the active model so it follows mid-turn model changes. ## Testing Add coverage for sparse and empty catalog values, preservation of generated context and outbound tool schemas, and description updates after mid-turn model changes. GitOrigin-RevId: 96cfa180f8f374dc868e4fabc337e49380e405da
This commit is contained in:
@@ -97,7 +97,10 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec {
|
||||
pub fn create_spawn_agent_tool_v2(
|
||||
options: SpawnAgentToolOptions,
|
||||
description_override: Option<&str>,
|
||||
) -> ToolSpec {
|
||||
let available_models_description = options.expose_spawn_agent_model_overrides.then(|| {
|
||||
spawn_agent_models_description(&options.available_models, options.multi_agent_version)
|
||||
});
|
||||
@@ -126,6 +129,7 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec {
|
||||
available_models_description.as_deref(),
|
||||
inherited_model_guidance,
|
||||
options.usage_hint_text,
|
||||
description_override,
|
||||
),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
@@ -738,12 +742,21 @@ fn spawn_agent_tool_description_v2(
|
||||
available_models_description: Option<&str>,
|
||||
inherited_model_guidance: Option<&str>,
|
||||
usage_hint_text: Option<String>,
|
||||
description: Option<&str>,
|
||||
) -> String {
|
||||
let agent_role_guidance = available_models_description.unwrap_or_default();
|
||||
let inherited_model_guidance = inherited_model_guidance.unwrap_or_default();
|
||||
|
||||
let tool_description = format!(
|
||||
r#"
|
||||
let tool_description = if let Some(description) = description {
|
||||
format!(
|
||||
r#"
|
||||
{agent_role_guidance}
|
||||
{description}
|
||||
{inherited_model_guidance}"#
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"
|
||||
{agent_role_guidance}
|
||||
Spawns an agent to work on the specified task. If your current task is `/root/task1` and you spawn_agent with task_name "task_3" the agent will have canonical task name `/root/task1/task_3`.
|
||||
You are then able to refer to this agent as `task_3` or `/root/task1/task_3` interchangeably. However an agent `/root/task2/task_3` would only be able to communicate with this agent via its canonical name `/root/task1/task_3`.
|
||||
@@ -754,7 +767,8 @@ It will be able to send you and other running agents messages, and its final ans
|
||||
The new agent's canonical task name will be provided to it along with the message.
|
||||
|
||||
Note that passing `fork_turns="none"` will not pass any surrounding context to the spawned subagent, which may cause the agent to lack the context it needs to complete its task, whereas `fork_turns="all"` will provide the subagent with all surrounding context."#
|
||||
);
|
||||
)
|
||||
};
|
||||
|
||||
if let Some(usage_hint_text) = usage_hint_text {
|
||||
return format!(
|
||||
|
||||
@@ -45,20 +45,23 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
|
||||
legacy.multi_agent_version = Some(MultiAgentVersion::V1);
|
||||
let mut disabled = model_preset("disabled", /*show_in_picker*/ true);
|
||||
disabled.multi_agent_version = Some(MultiAgentVersion::Disabled);
|
||||
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
|
||||
available_models: vec![
|
||||
model_preset("visible", /*show_in_picker*/ true),
|
||||
model_preset("hidden", /*show_in_picker*/ false),
|
||||
legacy,
|
||||
disabled,
|
||||
],
|
||||
agent_type_description: "role help".to_string(),
|
||||
expose_agent_type: true,
|
||||
hide_agent_type_model_reasoning: false,
|
||||
expose_spawn_agent_model_overrides: true,
|
||||
multi_agent_version: MultiAgentVersion::V2,
|
||||
usage_hint_text: None,
|
||||
});
|
||||
let tool = create_spawn_agent_tool_v2(
|
||||
SpawnAgentToolOptions {
|
||||
available_models: vec![
|
||||
model_preset("visible", /*show_in_picker*/ true),
|
||||
model_preset("hidden", /*show_in_picker*/ false),
|
||||
legacy,
|
||||
disabled,
|
||||
],
|
||||
agent_type_description: "role help".to_string(),
|
||||
expose_agent_type: true,
|
||||
hide_agent_type_model_reasoning: false,
|
||||
expose_spawn_agent_model_overrides: true,
|
||||
multi_agent_version: MultiAgentVersion::V2,
|
||||
usage_hint_text: None,
|
||||
},
|
||||
/*description_override*/ None,
|
||||
);
|
||||
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
description,
|
||||
@@ -127,6 +130,47 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_catalog_description_preserves_generated_context() {
|
||||
let options = SpawnAgentToolOptions {
|
||||
available_models: vec![model_preset("visible", /*show_in_picker*/ true)],
|
||||
agent_type_description: "Available agent roles: explorer".to_string(),
|
||||
expose_spawn_agent_model_overrides: true,
|
||||
multi_agent_version: MultiAgentVersion::V2,
|
||||
usage_hint_text: Some("Local usage hint.".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let ToolSpec::Function(default_tool) =
|
||||
create_spawn_agent_tool_v2(options.clone(), /*description_override*/ None)
|
||||
else {
|
||||
panic!("spawn_agent should be a function tool");
|
||||
};
|
||||
let ToolSpec::Function(mut configured_tool) =
|
||||
create_spawn_agent_tool_v2(options, Some("Catalog spawning guidance."))
|
||||
else {
|
||||
panic!("spawn_agent should be a function tool");
|
||||
};
|
||||
assert!(
|
||||
configured_tool
|
||||
.description
|
||||
.contains("Catalog spawning guidance.")
|
||||
);
|
||||
assert!(configured_tool.description.contains("`visible-model`"));
|
||||
assert!(
|
||||
configured_tool
|
||||
.description
|
||||
.contains(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE)
|
||||
);
|
||||
assert!(configured_tool.description.ends_with("Local usage hint."));
|
||||
assert!(
|
||||
!configured_tool
|
||||
.description
|
||||
.contains("Spawns an agent to work on the specified task.")
|
||||
);
|
||||
configured_tool.description = default_tool.description.clone();
|
||||
assert_eq!(configured_tool, default_tool);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
|
||||
let tool = create_spawn_agent_tool_v1(SpawnAgentToolOptions {
|
||||
@@ -182,22 +226,25 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_tool_caps_visible_model_summaries() {
|
||||
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
|
||||
available_models: vec![
|
||||
model_preset("first", /*show_in_picker*/ true),
|
||||
model_preset("second", /*show_in_picker*/ true),
|
||||
model_preset("third", /*show_in_picker*/ true),
|
||||
model_preset("fourth", /*show_in_picker*/ true),
|
||||
model_preset("fifth", /*show_in_picker*/ true),
|
||||
model_preset("sixth", /*show_in_picker*/ true),
|
||||
],
|
||||
agent_type_description: "role help".to_string(),
|
||||
expose_agent_type: true,
|
||||
hide_agent_type_model_reasoning: false,
|
||||
expose_spawn_agent_model_overrides: true,
|
||||
multi_agent_version: MultiAgentVersion::V2,
|
||||
usage_hint_text: None,
|
||||
});
|
||||
let tool = create_spawn_agent_tool_v2(
|
||||
SpawnAgentToolOptions {
|
||||
available_models: vec![
|
||||
model_preset("first", /*show_in_picker*/ true),
|
||||
model_preset("second", /*show_in_picker*/ true),
|
||||
model_preset("third", /*show_in_picker*/ true),
|
||||
model_preset("fourth", /*show_in_picker*/ true),
|
||||
model_preset("fifth", /*show_in_picker*/ true),
|
||||
model_preset("sixth", /*show_in_picker*/ true),
|
||||
],
|
||||
agent_type_description: "role help".to_string(),
|
||||
expose_agent_type: true,
|
||||
hide_agent_type_model_reasoning: false,
|
||||
expose_spawn_agent_model_overrides: true,
|
||||
multi_agent_version: MultiAgentVersion::V2,
|
||||
usage_hint_text: None,
|
||||
},
|
||||
/*description_override*/ None,
|
||||
);
|
||||
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) = tool else {
|
||||
panic!("spawn_agent should be a function tool");
|
||||
@@ -235,15 +282,18 @@ fn spawn_agent_tool_caps_reasoning_effort_value_length() {
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_tool_keeps_model_controls_when_spawn_metadata_is_hidden() {
|
||||
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
|
||||
available_models: vec![model_preset("visible", /*show_in_picker*/ true)],
|
||||
agent_type_description: "role help".to_string(),
|
||||
expose_agent_type: false,
|
||||
hide_agent_type_model_reasoning: true,
|
||||
expose_spawn_agent_model_overrides: true,
|
||||
multi_agent_version: MultiAgentVersion::V2,
|
||||
usage_hint_text: None,
|
||||
});
|
||||
let tool = create_spawn_agent_tool_v2(
|
||||
SpawnAgentToolOptions {
|
||||
available_models: vec![model_preset("visible", /*show_in_picker*/ true)],
|
||||
agent_type_description: "role help".to_string(),
|
||||
expose_agent_type: false,
|
||||
hide_agent_type_model_reasoning: true,
|
||||
expose_spawn_agent_model_overrides: true,
|
||||
multi_agent_version: MultiAgentVersion::V2,
|
||||
usage_hint_text: None,
|
||||
},
|
||||
/*description_override*/ None,
|
||||
);
|
||||
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
description,
|
||||
@@ -268,15 +318,18 @@ fn spawn_agent_tool_keeps_model_controls_when_spawn_metadata_is_hidden() {
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_tool_hides_model_controls_without_override_exposure() {
|
||||
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
|
||||
available_models: vec![model_preset("visible", /*show_in_picker*/ true)],
|
||||
agent_type_description: "role help".to_string(),
|
||||
expose_agent_type: false,
|
||||
hide_agent_type_model_reasoning: true,
|
||||
expose_spawn_agent_model_overrides: false,
|
||||
multi_agent_version: MultiAgentVersion::V2,
|
||||
usage_hint_text: None,
|
||||
});
|
||||
let tool = create_spawn_agent_tool_v2(
|
||||
SpawnAgentToolOptions {
|
||||
available_models: vec![model_preset("visible", /*show_in_picker*/ true)],
|
||||
agent_type_description: "role help".to_string(),
|
||||
expose_agent_type: false,
|
||||
hide_agent_type_model_reasoning: true,
|
||||
expose_spawn_agent_model_overrides: false,
|
||||
multi_agent_version: MultiAgentVersion::V2,
|
||||
usage_hint_text: None,
|
||||
},
|
||||
Some(""),
|
||||
);
|
||||
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
description,
|
||||
|
||||
@@ -25,11 +25,18 @@ use codex_tools::ToolSpec;
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Handler {
|
||||
options: SpawnAgentToolOptions,
|
||||
description_override: Option<String>,
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
pub(crate) fn new(options: SpawnAgentToolOptions) -> Self {
|
||||
Self { options }
|
||||
pub(crate) fn new(
|
||||
options: SpawnAgentToolOptions,
|
||||
description_override: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
options,
|
||||
description_override,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +46,7 @@ impl ToolExecutor<ToolInvocation> for Handler {
|
||||
}
|
||||
|
||||
fn spec(&self) -> ToolSpec {
|
||||
create_spawn_agent_tool_v2(self.options.clone())
|
||||
create_spawn_agent_tool_v2(self.options.clone(), self.description_override.as_deref())
|
||||
}
|
||||
|
||||
fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
|
||||
|
||||
@@ -1248,6 +1248,8 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too
|
||||
let turn_context = context.turn_context;
|
||||
if collab_tools_enabled(turn_context, context.model_info) {
|
||||
if multi_agent_v2_enabled(turn_context) {
|
||||
let spawn_agent_description = ResolvedModelMessages::from_model(context.model_info)
|
||||
.spawn_agent_description_override();
|
||||
let exposure = if turn_context.config.multi_agent_v2.non_code_mode_only {
|
||||
ToolExposure::DirectModelOnly
|
||||
} else {
|
||||
@@ -1262,18 +1264,25 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too
|
||||
turn_context.config.multi_agent_v2.hide_spawn_agent_metadata;
|
||||
registry.register_trusted_with_exposure(
|
||||
multi_agent_v2_handler(
|
||||
SpawnAgentHandlerV2::new(SpawnAgentToolOptions {
|
||||
available_models: turn_context.available_models.clone(),
|
||||
agent_type_description,
|
||||
expose_agent_type: !turn_context.config.agent_roles.is_empty(),
|
||||
hide_agent_type_model_reasoning: hide_spawn_agent_metadata,
|
||||
expose_spawn_agent_model_overrides: turn_context
|
||||
.config
|
||||
.multi_agent_v2
|
||||
.expose_spawn_agent_model_overrides,
|
||||
multi_agent_version: turn_context.multi_agent_version,
|
||||
usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(),
|
||||
}),
|
||||
SpawnAgentHandlerV2::new(
|
||||
SpawnAgentToolOptions {
|
||||
available_models: turn_context.available_models.clone(),
|
||||
agent_type_description,
|
||||
expose_agent_type: !turn_context.config.agent_roles.is_empty(),
|
||||
hide_agent_type_model_reasoning: hide_spawn_agent_metadata,
|
||||
expose_spawn_agent_model_overrides: turn_context
|
||||
.config
|
||||
.multi_agent_v2
|
||||
.expose_spawn_agent_model_overrides,
|
||||
multi_agent_version: turn_context.multi_agent_version,
|
||||
usage_hint_text: turn_context
|
||||
.config
|
||||
.multi_agent_v2
|
||||
.usage_hint_text
|
||||
.clone(),
|
||||
},
|
||||
spawn_agent_description.map(str::to_owned),
|
||||
),
|
||||
tool_namespace,
|
||||
),
|
||||
exposure,
|
||||
|
||||
@@ -182,6 +182,7 @@ mod skill_approval;
|
||||
mod skills;
|
||||
mod skills_extension;
|
||||
mod spawn_agent_description;
|
||||
mod spawn_agent_tool_descriptions;
|
||||
mod sqlite_state;
|
||||
mod startup_cancellation;
|
||||
mod step_settings;
|
||||
|
||||
@@ -76,6 +76,7 @@ async fn persistent_async_message_guidance_follows_tool_availability(
|
||||
send_user_message_async: Some(ToolMessage {
|
||||
description: Some("Catalog async message description.".to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
})
|
||||
.with_config(|config| {
|
||||
@@ -248,6 +249,7 @@ async fn freeform_async_message_emits_an_item_without_ending_the_turn(
|
||||
send_user_message_async: Some(ToolMessage {
|
||||
description: Some("Questions only.".to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
})
|
||||
.with_config(move |config| {
|
||||
@@ -340,10 +342,10 @@ async fn freeform_async_message_emits_an_item_without_ending_the_turn(
|
||||
|
||||
#[test_case(None, "send_user_message_async"; "fallback_description")]
|
||||
#[test_case(None, "request_user_input_async"; "current_catalog_name")]
|
||||
#[test_case(Some(ToolMessages { send_user_message_async: None }), "send_user_message_async"; "missing_tool")]
|
||||
#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage::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()) }) }), "send_user_message_async"; "catalog_description")]
|
||||
#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some(String::new()) }) }), "send_user_message_async"; "empty_description")]
|
||||
#[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")]
|
||||
#[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>,
|
||||
|
||||
90
codex-rs/core/tests/suite/spawn_agent_tool_descriptions.rs
Normal file
90
codex-rs/core/tests/suite/spawn_agent_tool_descriptions.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
//! Verifies catalog tool descriptions reach the V2 outbound tool without changing its parameters.
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::config::AgentRoleConfig;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::openai_models::ToolMessages;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::namespace_child_tool;
|
||||
use core_test_support::responses::sse_completed;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use test_case::test_case;
|
||||
|
||||
#[test_case(json!(null); "missing_tools")]
|
||||
#[test_case(json!({}); "missing_multi_agent")]
|
||||
#[test_case(json!({"multi_agent": null}); "null_multi_agent")]
|
||||
#[test_case(json!({"multi_agent": {}}); "missing_spawn_agent")]
|
||||
#[test_case(json!({"multi_agent": {"spawn_agent": null}}); "null_spawn_agent")]
|
||||
#[test_case(json!({"multi_agent": {"spawn_agent": {}}}); "missing_description")]
|
||||
#[test_case(json!({"multi_agent": {"spawn_agent": {"description": null}}}); "null_description")]
|
||||
#[test_case(json!({"multi_agent": {"spawn_agent": {"description": "Catalog spawn."}}}); "catalog_description")]
|
||||
#[test_case(json!({"multi_agent": {"spawn_agent": {"description": ""}}}); "empty_description")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn spawn_agent_catalog_descriptions_preserve_outbound_schema(
|
||||
tool_messages: Value,
|
||||
) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let response = mount_sse_sequence(
|
||||
&server,
|
||||
vec![sse_completed("resp-default"), sse_completed("resp-catalog")],
|
||||
)
|
||||
.await;
|
||||
for messages in [
|
||||
None,
|
||||
serde_json::from_value::<Option<ToolMessages>>(tool_messages.clone())?,
|
||||
] {
|
||||
let test = test_codex()
|
||||
.with_model_info_override("gpt-5.2", move |model| {
|
||||
model.multi_agent_version = Some(MultiAgentVersion::V2);
|
||||
model.model_messages.as_mut().expect("model messages").tools = messages;
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("enable V2");
|
||||
config.multi_agent_v2.tool_namespace = Some("delegation".to_string());
|
||||
config.multi_agent_v2.hide_spawn_agent_metadata = false;
|
||||
config.multi_agent_v2.expose_spawn_agent_model_overrides = true;
|
||||
config.multi_agent_v2.usage_hint_text = Some("Local delegation hint.".to_string());
|
||||
config.agent_roles.insert(
|
||||
"researcher".to_string(),
|
||||
AgentRoleConfig {
|
||||
description: Some("Research the assigned question.".to_string()),
|
||||
config_file: None,
|
||||
nickname_candidates: None,
|
||||
},
|
||||
);
|
||||
})
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
test.submit_turn("Inspect the available tools.").await?;
|
||||
}
|
||||
|
||||
let requests = response.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let mut expected = namespace_child_tool(&requests[0].body_json(), "delegation", "spawn_agent")
|
||||
.expect("default spawn agent tool")
|
||||
.clone();
|
||||
let actual = namespace_child_tool(&requests[1].body_json(), "delegation", "spawn_agent")
|
||||
.expect("catalog spawn agent tool")
|
||||
.clone();
|
||||
let messages = &tool_messages["multi_agent"]["spawn_agent"];
|
||||
if let Some(description) = messages["description"].as_str() {
|
||||
let actual_description = actual["description"].as_str().expect("tool description");
|
||||
assert!(actual_description.contains(description));
|
||||
assert!(!actual_description.contains("Spawns an agent to work on the specified task."));
|
||||
assert!(actual_description.ends_with("Local delegation hint."));
|
||||
expected["description"] = actual["description"].clone();
|
||||
}
|
||||
assert_eq!(actual, expected);
|
||||
Ok(())
|
||||
}
|
||||
@@ -44,6 +44,7 @@ use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::openai_models::MultiAgentMessages;
|
||||
use codex_protocol::openai_models::MultiAgentModeMessages;
|
||||
use codex_protocol::openai_models::MultiAgentRoleMessages;
|
||||
use codex_protocol::openai_models::MultiAgentToolMessages;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::openai_models::ToolMessage;
|
||||
@@ -86,6 +87,7 @@ use core_test_support::responses::mount_models_once;
|
||||
use core_test_support::responses::mount_response_sequence;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::namespace_child_tool;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::sse_completed;
|
||||
use core_test_support::responses::sse_response;
|
||||
@@ -2224,7 +2226,7 @@ async fn model_activation_uses_destination_metadata_defaults(
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn request_user_input_async_description_follows_mid_turn_model_changes() -> Result<()> {
|
||||
async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
@@ -2238,6 +2240,11 @@ async fn request_user_input_async_description_follows_mid_turn_model_changes() -
|
||||
.await;
|
||||
let test = step_settings_test()
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("test config should allow feature update");
|
||||
config.multi_agent_v2.expose_spawn_agent_model_overrides = false;
|
||||
for model in &mut config
|
||||
.model_catalog
|
||||
.as_mut()
|
||||
@@ -2255,6 +2262,11 @@ async fn request_user_input_async_description_follows_mid_turn_model_changes() -
|
||||
send_user_message_async: Some(ToolMessage {
|
||||
description: Some(format!("Async message description for {}.", model.slug)),
|
||||
}),
|
||||
multi_agent: Some(MultiAgentToolMessages {
|
||||
spawn_agent: Some(ToolMessage {
|
||||
description: Some(format!("Spawn description for {}.", model.slug)),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -2290,13 +2302,20 @@ async fn request_user_input_async_description_follows_mid_turn_model_changes() -
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "request_user_input_async")
|
||||
.expect("async message tool");
|
||||
json!({"model": body["model"], "description": tool["description"]})
|
||||
let spawn = namespace_child_tool(&body, "collaboration", "spawn_agent")
|
||||
.expect("spawn agent tool");
|
||||
json!({
|
||||
"model": body["model"],
|
||||
"async_description": tool["description"],
|
||||
"spawn_description": spawn["description"].as_str().expect("spawn description").trim(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
[MODEL_A, MODEL_B]
|
||||
.map(|model| json!({
|
||||
"model": model,
|
||||
"description": format!("Async message description for {model}."),
|
||||
"async_description": format!("Async message description for {model}."),
|
||||
"spawn_description": format!("Spawn description for {model}."),
|
||||
}))
|
||||
.to_vec(),
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ use codex_protocol::openai_models::ModelTokenBudgetConfig;
|
||||
use codex_protocol::openai_models::MultiAgentMessages;
|
||||
use codex_protocol::openai_models::MultiAgentModeMessages;
|
||||
use codex_protocol::openai_models::MultiAgentRoleMessages;
|
||||
use codex_protocol::openai_models::MultiAgentToolMessages;
|
||||
use codex_protocol::openai_models::PermissionMessages;
|
||||
use codex_protocol::openai_models::ToolMessage;
|
||||
use codex_protocol::openai_models::ToolMessages;
|
||||
@@ -86,6 +87,11 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() {
|
||||
send_user_message_async: Some(ToolMessage {
|
||||
description: Some(async_message_description.to_string()),
|
||||
}),
|
||||
multi_agent: Some(MultiAgentToolMessages {
|
||||
spawn_agent: Some(ToolMessage {
|
||||
description: Some("Catalog spawn description.".to_string()),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
instructions_template: Some("template".to_string()),
|
||||
instructions_variables: Some(ModelInstructionsVariables {
|
||||
@@ -147,6 +153,16 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() {
|
||||
personality_pragmatic: Some("pragmatic".to_string()),
|
||||
}),
|
||||
persistent_instructions: Some(String::new()),
|
||||
tools: Some(ToolMessages {
|
||||
send_user_message_async: Some(ToolMessage {
|
||||
description: Some(String::new()),
|
||||
}),
|
||||
multi_agent: Some(MultiAgentToolMessages {
|
||||
spawn_agent: Some(ToolMessage {
|
||||
description: Some(String::new()),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
approvals: Some(ApprovalMessages {
|
||||
on_request: Some("user approvals".to_string()),
|
||||
on_request_auto_review: None,
|
||||
|
||||
@@ -151,6 +151,15 @@ impl<'a> ResolvedModelMessages<'a> {
|
||||
.unwrap_or(REQUEST_USER_INPUT_ASYNC_DESCRIPTION)
|
||||
}
|
||||
|
||||
/// Returns the V2 spawn tool's static description override, leaving missing text to the tool.
|
||||
pub fn spawn_agent_description_override(&self) -> Option<&'a str> {
|
||||
self.catalog_messages
|
||||
.and_then(|messages| messages.tools.as_ref())
|
||||
.and_then(|tools| tools.multi_agent.as_ref())
|
||||
.and_then(|tools| tools.spawn_agent.as_ref())
|
||||
.and_then(|tool| tool.description.as_deref())
|
||||
}
|
||||
|
||||
/// Resolves persistent-mode instructions without deciding whether the mode is active.
|
||||
pub fn persistent_instructions(&self) -> &'a str {
|
||||
self.catalog_messages
|
||||
|
||||
@@ -576,6 +576,8 @@ pub struct ConfirmationPolicies {
|
||||
pub struct ToolMessages {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub send_user_message_async: Option<ToolMessage>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub multi_agent: Option<MultiAgentToolMessages>,
|
||||
}
|
||||
|
||||
/// Model-owned messages for a built-in tool.
|
||||
@@ -587,6 +589,15 @@ pub struct ToolMessage {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Model-owned descriptions for Multi-Agent V2 tools, independent of their runtime 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
|
||||
/// suppresses it. Generated model information and local usage hints are retained.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub spawn_agent: Option<ToolMessage>,
|
||||
}
|
||||
|
||||
/// Model-owned defaults for the context-window token-budget feature.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
||||
pub struct ModelTokenBudgetConfig {
|
||||
@@ -1022,6 +1033,7 @@ mod tests {
|
||||
(
|
||||
expected.as_ref().map(|tool| ToolMessages {
|
||||
send_user_message_async: tool.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
expected.map(|tool| tool.map(|tool| match tool.description {
|
||||
Some(description) => serde_json::json!({"description": description}),
|
||||
@@ -1032,6 +1044,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_messages_preserve_sparse_and_empty_values() {
|
||||
for (value, expected) in [
|
||||
(serde_json::json!({}), serde_json::json!({})),
|
||||
(
|
||||
serde_json::json!({"multi_agent": null}),
|
||||
serde_json::json!({}),
|
||||
),
|
||||
(
|
||||
serde_json::json!({"multi_agent": {"spawn_agent": null}}),
|
||||
serde_json::json!({"multi_agent": {}}),
|
||||
),
|
||||
(
|
||||
serde_json::json!({"multi_agent": {"spawn_agent": {"description": null}}}),
|
||||
serde_json::json!({"multi_agent": {"spawn_agent": {}}}),
|
||||
),
|
||||
(
|
||||
serde_json::json!({"multi_agent": {"spawn_agent": {"description": "Catalog spawn"}}}),
|
||||
serde_json::json!({"multi_agent": {"spawn_agent": {"description": "Catalog spawn"}}}),
|
||||
),
|
||||
(
|
||||
serde_json::json!({"multi_agent": {"spawn_agent": {"description": ""}}}),
|
||||
serde_json::json!({"multi_agent": {"spawn_agent": {"description": ""}}}),
|
||||
),
|
||||
] {
|
||||
let tools: ToolMessages =
|
||||
serde_json::from_value(value).expect("deserialize tool messages");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&tools).expect("serialize tool messages"),
|
||||
expected
|
||||
);
|
||||
let restored: ToolMessages =
|
||||
serde_json::from_value(expected).expect("restore tool messages");
|
||||
assert_eq!(restored, tools);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_messages_preserve_missing_and_empty_values() {
|
||||
let messages: ModelMessages = from_str(
|
||||
@@ -1331,6 +1380,11 @@ mod tests {
|
||||
send_user_message_async: Some(ToolMessage {
|
||||
description: Some("Catalog description".to_string()),
|
||||
}),
|
||||
multi_agent: Some(MultiAgentToolMessages {
|
||||
spawn_agent: Some(ToolMessage {
|
||||
description: Some("Catalog spawn description".to_string()),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
instructions_template: None,
|
||||
instructions_variables: None,
|
||||
@@ -1403,6 +1457,7 @@ mod tests {
|
||||
send_user_message_async: Some(ToolMessage {
|
||||
description: Some(String::new()),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
instructions_template: Some("canonical instructions".to_string()),
|
||||
..Default::default()
|
||||
|
||||
Reference in New Issue
Block a user