Source async user message descriptions from the model catalog (#41461)

## What changed

- Add model message metadata for built-in tool descriptions.
- Use the active step model's catalog description for `send_user_message_async`, including after a mid-turn model change.
- Fall back to the built-in description when catalog metadata is absent, while preserving an explicitly empty description.

## Testing

- Cover catalog serialization, fallback and empty-value behavior, and model changes within a turn.

GitOrigin-RevId: 72b953214ab9708931065321debf12133f8d2d40
This commit is contained in:
rhan-oai
2026-08-29 03:44:01 +00:00
committed by copyberry
parent 03861e69ef
commit 3c062df036
18 changed files with 298 additions and 20 deletions
@@ -35,6 +35,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
upgrade: preset.upgrade.as_ref().map(Into::into),
model_messages: Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some("base instructions".to_string()),
instructions_variables: None,
approvals: None,
@@ -377,6 +377,7 @@ async fn guardian_review_session_config_prefers_managed_policy_and_uses_catalog_
parent_config.guardian_policy_config = Some(managed_policy.to_string());
let model_messages = ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -417,6 +418,7 @@ async fn guardian_review_session_config_preserves_explicit_empty_catalog_policy(
let parent_config = crate::config::test_config().await;
let model_messages = ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -465,6 +467,7 @@ async fn guardian_review_session_config_preserves_explicit_empty_catalog_templat
let catalog_policy = "Use the catalog Guardian policy.";
let model_messages = ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
+3 -2
View File
@@ -3467,13 +3467,14 @@ impl Session {
});
extension_data.insert(selected_plugins.clone());
turn_context.extension_data.insert(selected_plugins);
// Tool planning still uses the admitted turn. Migrating it to the
// captured model is a separate step from diagnostic activation.
// Tool availability still follows the admitted turn; the async message
// description comes from the captured step model.
let tool_router = turn::built_tools(
self.as_ref(),
turn_context.as_ref(),
// TODO(CDXENT-441): use the step scoped model
turn_context.model_info(),
settings.model_info.model_messages.as_ref(),
&environments,
&mcp,
&extension_data,
+4
View File
@@ -92,6 +92,7 @@ use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelMessages;
use codex_protocol::protocol::AgentMessageContentDeltaEvent;
use codex_protocol::protocol::AgentReasoningSectionBreakEvent;
use codex_protocol::protocol::CodexErrorInfo;
@@ -1504,6 +1505,7 @@ pub(crate) async fn prepare_tool_recommendations(
}
}
#[allow(clippy::too_many_arguments)]
#[instrument(level = "trace",
skip_all,
fields(
@@ -1516,6 +1518,7 @@ pub(crate) async fn built_tools(
sess: &Session,
turn_context: &TurnContext,
model_info: &codex_protocol::openai_models::ModelInfo,
model_messages: Option<&ModelMessages>,
environments: &TurnEnvironmentSnapshot,
mcp: &Arc<codex_mcp::McpBinding>,
step_store: &ExtensionData,
@@ -1585,6 +1588,7 @@ pub(crate) async fn built_tools(
sess,
turn_context,
model_info,
model_messages,
environments,
mcp,
apps_enabled,
@@ -20,7 +20,9 @@ use std::collections::BTreeMap;
const TOOL_NAME: &str = "send_user_message_async";
pub struct SendUserMessageAsyncHandler;
pub struct SendUserMessageAsyncHandler {
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -43,8 +45,10 @@ impl ToolExecutor<ToolInvocation> for SendUserMessageAsyncHandler {
ToolSpec::Function(ResponsesApiTool {
name: TOOL_NAME.to_string(),
description: "Send a concise message that needs the user's attention during ongoing work. The tool returns immediately without ending the turn or waiting for a reply; any reply arrives asynchronously as a new user message.\nOnly use this tool to ask for missing information, preferences, constraints, clarification, or approval. The message should be concise, easy to read and understand, and at the right level of abstraction that is appropriate for the user and task at hand."
.to_string(),
description: self.description.clone().unwrap_or_else(|| {
"Send a concise message that needs the user's attention during ongoing work. The tool returns immediately without ending the turn or waiting for a reply; any reply arrives asynchronously as a new user message.\nOnly use this tool to ask for missing information, preferences, constraints, clarification, or approval. The message should be concise, easy to read and understand, and at the right level of abstraction that is appropriate for the user and task at hand."
.to_string()
}),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
+15 -1
View File
@@ -75,6 +75,7 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelMessages;
use codex_protocol::openai_models::ToolMode;
use codex_protocol::protocol::MultiAgentVersion;
use codex_tools::ResponsesApiNamespace;
@@ -109,6 +110,7 @@ const IMAGEGEN_TOOL_NAME: &str = "imagegen";
struct CoreToolPlanContext<'a> {
turn_context: &'a TurnContext,
model_info: &'a ModelInfo,
model_messages: Option<&'a ModelMessages>,
environments: &'a TurnEnvironmentSnapshot,
mcp: &'a codex_mcp::McpBinding,
tool_suggest_candidates: Option<&'a crate::tools::router::ToolSuggestCandidates>,
@@ -123,6 +125,7 @@ pub(crate) fn build_tool_router(
session: &Session,
turn_context: &TurnContext,
model_info: &ModelInfo,
model_messages: Option<&ModelMessages>,
environments: &TurnEnvironmentSnapshot,
mcp: &Arc<codex_mcp::McpBinding>,
apps_enabled: bool,
@@ -138,6 +141,7 @@ pub(crate) fn build_tool_router(
let context = CoreToolPlanContext {
turn_context,
model_info,
model_messages,
environments,
mcp,
tool_suggest_candidates,
@@ -290,6 +294,7 @@ pub(crate) fn build_core_tool_registry(
let context = CoreToolPlanContext {
turn_context,
model_info,
model_messages: model_info.model_messages.as_ref(),
environments,
mcp,
tool_suggest_candidates,
@@ -1172,7 +1177,16 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool
.iter()
.any(|tool| tool == "send_user_message_async")
{
registry.add_with_exposure(SendUserMessageAsyncHandler, ToolExposure::DirectModelOnly);
registry.add_with_exposure(
SendUserMessageAsyncHandler {
description: context
.model_messages
.and_then(|messages| messages.tools.as_ref())
.and_then(|tools| tools.send_user_message_async.as_ref())
.and_then(|tool| tool.description.clone()),
},
ToolExposure::DirectModelOnly,
);
}
if environment_mode.has_environment() && features.enabled(Feature::RequestPermissionsTool) {
@@ -529,6 +529,7 @@ async fn internal_guardian_sessions_exclude_optional_core_tools() {
&session,
step_context.turn.as_ref(),
step_context.turn.model_info(),
step_context.settings.model_info.model_messages.as_ref(),
&step_context.environments,
&step_context.mcp,
/*apps_enabled*/ false,
@@ -581,6 +582,7 @@ async fn internal_guardian_sessions_respect_managed_shell_restrictions() {
&session,
step_context.turn.as_ref(),
step_context.turn.model_info(),
step_context.settings.model_info.model_messages.as_ref(),
&step_context.environments,
&step_context.mcp,
/*apps_enabled*/ false,
@@ -617,6 +619,7 @@ async fn internal_guardian_sessions_preserve_code_mode() {
&session,
step_context.turn.as_ref(),
step_context.turn.model_info(),
step_context.settings.model_info.model_messages.as_ref(),
&step_context.environments,
&step_context.mcp,
/*apps_enabled*/ false,
@@ -687,6 +690,7 @@ async fn internal_guardian_sessions_require_managed_secondary_environments() {
&session,
step_context.turn.as_ref(),
step_context.turn.model_info(),
step_context.settings.model_info.model_messages.as_ref(),
&step_context.environments,
&step_context.mcp,
/*apps_enabled*/ false,
@@ -32,6 +32,7 @@ async fn catalog_permission_message_loaded_from_remote_models_is_sent() -> Resul
let mut model = model_info_from_slug(model_slug);
model.model_messages = Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -76,6 +76,7 @@ fn model_with_collaboration_messages(
let mut model = model_info_from_slug(slug);
let model_messages = model.model_messages.get_or_insert(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -505,6 +505,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
upgrade: None,
model_messages: Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some("base instructions".to_string()),
instructions_variables: None,
approvals: None,
@@ -48,6 +48,7 @@ fn model_with_approval_messages(
let mut model = model_info_from_slug(slug);
model.model_messages = Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: None,
approvals: Some(ApprovalMessages {
@@ -74,6 +75,7 @@ fn model_with_permission_messages(
let mut model = model_info_from_slug(slug);
model.model_messages = Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -230,6 +232,7 @@ async fn catalog_non_on_request_approval_messages_are_sent_in_initial_permission
let mut model = model_info_from_slug(model_slug);
model.model_messages = Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: None,
approvals: Some(approvals),
+2
View File
@@ -634,6 +634,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
upgrade: None,
model_messages: Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some("Base instructions\n{{ personality }}\n".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: Some(default_personality_message.to_string()),
@@ -763,6 +764,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
upgrade: None,
model_messages: Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some("Base instructions\n{{ personality }}\n".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: None,
@@ -116,6 +116,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<(
display_name: "GPT 5.3 Codex".to_string(),
model_messages: Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some("use specific prefix".to_string()),
instructions_variables: None,
approvals: None,
@@ -133,6 +134,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<(
display_name: "GPT 5.3".to_string(),
model_messages: Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some("use generic prefix".to_string()),
instructions_variables: None,
approvals: None,
@@ -408,6 +410,7 @@ async fn remote_models_long_model_slug_is_sent_with_supported_reasoning(
remote_model.default_reasoning_summary = ReasoningSummary::Detailed;
remote_model.model_messages = Some(ModelMessages {
persistent_instructions: catalog_instructions.map(str::to_string),
tools: None,
instructions_template: Some(base_instructions.to_string()),
instructions_variables: None,
approvals: None,
@@ -851,6 +854,7 @@ async fn remote_models_apply_legacy_instructions() -> Result<()> {
upgrade: None,
model_messages: Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some(remote_instructions.to_string()),
instructions_variables: None,
approvals: None,
@@ -8,6 +8,8 @@ use codex_protocol::items::TurnItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::openai_models::ToolMessage;
use codex_protocol::openai_models::ToolMessages;
use codex_protocol::openai_models::ToolMode;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::SessionSource;
@@ -63,9 +65,16 @@ async fn persistent_async_message_guidance_follows_tool_availability(
effort: ReasoningEffort::Persistent,
description: ReasoningEffort::Persistent.to_string(),
});
if let Some(messages) = model.model_messages.as_mut() {
messages.persistent_instructions = None;
}
let messages = model
.model_messages
.as_mut()
.expect("model instruction metadata");
messages.persistent_instructions = None;
messages.tools = Some(ToolMessages {
send_user_message_async: Some(ToolMessage {
description: Some("Catalog async message description.".to_string()),
}),
});
})
.with_config(|config| {
config.model_reasoning_effort = Some(ReasoningEffort::Persistent);
@@ -110,8 +119,15 @@ async fn persistent_async_message_guidance_follows_tool_availability(
Ok(())
}
#[test_case(None; "fallback_description")]
#[test_case(Some(ToolMessages { send_user_message_async: None }); "missing_tool")]
#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage::default()) }); "missing_description")]
#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some("Catalog async message description.".to_string()) }) }); "catalog_description")]
#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some(String::new()) }) }); "empty_description")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn send_user_message_async_emits_item_and_does_not_end_the_turn() -> Result<()> {
async fn send_user_message_async_emits_item_and_does_not_end_the_turn(
tool_messages: Option<ToolMessages>,
) -> Result<()> {
skip_if_no_network!(Ok(()));
const CALL_ID: &str = "async-message-call";
@@ -139,12 +155,25 @@ async fn send_user_message_async_emits_item_and_does_not_end_the_turn() -> Resul
],
)
.await;
let expected_description = tool_messages
.as_ref()
.and_then(|tools| tools.send_user_message_async.as_ref())
.and_then(|tool| tool.description.as_deref())
.unwrap_or(
"Send a concise message that needs the user's attention during ongoing work. The tool returns immediately without ending the turn or waiting for a reply; any reply arrives asynchronously as a new user message.\nOnly use this tool to ask for missing information, preferences, constraints, clarification, or approval. The message should be concise, easy to read and understand, and at the right level of abstraction that is appropriate for the user and task at hand.",
)
.to_string();
let test = test_codex()
.with_model_info_override("gpt-5.2", |model| {
.with_model_info_override("gpt-5.2", move |model| {
model.tool_mode = Some(ToolMode::CodeModeOnly);
model
.experimental_supported_tools
.push("send_user_message_async".to_string());
model
.model_messages
.as_mut()
.expect("model instruction metadata")
.tools = tool_messages;
})
.build_with_auto_env(&server)
.await?;
@@ -207,16 +236,16 @@ async fn send_user_message_async_emits_item_and_does_not_end_the_turn() -> Resul
let requests = responses.requests();
assert_eq!(requests.len(), 2);
assert!(
requests[0].body_json()["tools"]
for request in &requests {
let body = request.body_json();
let tool = body["tools"]
.as_array()
.is_some_and(|tools| {
tools.iter().any(|tool| {
tool["type"] == "function" && tool["name"] == "send_user_message_async"
})
}),
"the async message tool should be directly visible to the model"
);
.expect("request tools")
.iter()
.find(|tool| tool["type"] == "function" && tool["name"] == "send_user_message_async")
.expect("the async message tool should be directly visible to the model");
assert_eq!(tool["description"], expected_description);
}
assert_eq!(
requests[1].function_call_output_text(CALL_ID),
Some(r#"{"accepted":true}"#.to_string())
@@ -17,6 +17,8 @@ use codex_protocol::openai_models::ModelTokenBudgetConfig;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::openai_models::ToolMessage;
use codex_protocol::openai_models::ToolMessages;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::CONTEXT_WINDOW_GUIDANCE_CLOSE_TAG;
use codex_protocol::protocol::CONTEXT_WINDOW_GUIDANCE_OPEN_TAG;
@@ -1128,6 +1130,90 @@ async fn model_activation_uses_destination_metadata_defaults(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn send_user_message_async_description_follows_mid_turn_model_changes() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let response_mock = mount_sse_sequence(
&server,
vec![
paused_response("resp-1", "pause-before-async-message-model-change"),
sse_completed("resp-2"),
],
)
.await;
let test = step_settings_test()
.with_config(|config| {
for model in &mut config
.model_catalog
.as_mut()
.expect("controlled model catalog")
.models
{
model
.experimental_supported_tools
.push("send_user_message_async".to_string());
model
.model_messages
.as_mut()
.expect("model instruction metadata")
.tools = Some(ToolMessages {
send_user_message_async: Some(ToolMessage {
description: Some(format!("Async message description for {}.", model.slug)),
}),
});
}
})
.build_with_auto_env(&server)
.await?;
let paused = start_paused_turn(&test.codex).await?;
assert_eq!(
submit_turn_settings(
&test.codex,
&paused.turn_id,
TurnSettingsUpdate {
model: Some(MODEL_B.to_string()),
..Default::default()
}
)
.await?,
TurnSettingsUpdateOutcome::Applied
);
answer_paused_turn(&test.codex, &paused.turn_id).await?;
wait_for_event(&test.codex, |event| match event {
EventMsg::Error(error) => panic!("model activation failed: {}", error.message),
EventMsg::TurnComplete(_) => true,
_ => false,
})
.await;
assert_eq!(
response_mock
.requests()
.iter()
.map(|request| {
let body = request.body_json();
let tool = body["tools"]
.as_array()
.expect("request tools")
.iter()
.find(|tool| tool["name"] == "send_user_message_async")
.expect("async message tool");
json!({"model": body["model"], "description": tool["description"]})
})
.collect::<Vec<_>>(),
[MODEL_A, MODEL_B]
.map(|model| json!({
"model": model,
"description": format!("Async message description for {model}."),
}))
.to_vec(),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn persistent_instructions_follow_mid_turn_model_changes() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -52,6 +52,7 @@ pub fn with_config_overrides(mut model: ModelInfo, config: &ModelsManagerConfig)
if let Some(base_instructions) = &config.base_instructions {
let model_messages = model.model_messages.get_or_insert(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -192,6 +193,7 @@ fn local_model_messages_for_slug(slug: &str) -> ModelMessages {
match slug {
"gpt-5.2-codex" | "exp-codex-personality" => ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some(format!(
"{DEFAULT_PERSONALITY_HEADER}\n\n{PERSONALITY_PLACEHOLDER}\n\n{BASE_INSTRUCTIONS}"
)),
@@ -211,6 +213,7 @@ fn local_model_messages_for_slug(slug: &str) -> ModelMessages {
},
_ => ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some(BASE_INSTRUCTIONS.to_string()),
instructions_variables: None,
approvals: None,
@@ -11,6 +11,8 @@ use codex_protocol::openai_models::MultiAgentMessages;
use codex_protocol::openai_models::MultiAgentModeMessages;
use codex_protocol::openai_models::MultiAgentRoleMessages;
use codex_protocol::openai_models::PermissionMessages;
use codex_protocol::openai_models::ToolMessage;
use codex_protocol::openai_models::ToolMessages;
use pretty_assertions::assert_eq;
fn config_with_personality(personality: Option<Personality>) -> ModelsManagerConfig {
@@ -25,6 +27,7 @@ fn config_with_personality(personality: Option<Personality>) -> ModelsManagerCon
fn base_instruction_override_is_literal_and_preserves_catalog_messages() {
let override_instructions = "override {{ personality }}";
let persistent_instructions = "Follow up on the active task.";
let async_message_description = "Catalog async message description.";
let mut model = model_info_from_slug("unknown-model");
let approvals = ApprovalMessages {
on_request: Some("user approvals".to_string()),
@@ -75,6 +78,11 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() {
};
model.model_messages = Some(ModelMessages {
persistent_instructions: Some(persistent_instructions.to_string()),
tools: Some(ToolMessages {
send_user_message_async: Some(ToolMessage {
description: Some(async_message_description.to_string()),
}),
}),
instructions_template: Some("template".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: Some("default".to_string()),
@@ -101,6 +109,11 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() {
updated.model_messages,
Some(ModelMessages {
persistent_instructions: Some(persistent_instructions.to_string()),
tools: Some(ToolMessages {
send_user_message_async: Some(ToolMessage {
description: Some(async_message_description.to_string()),
}),
}),
instructions_template: Some(override_instructions.to_string()),
instructions_variables: None,
approvals: Some(approvals),
@@ -130,6 +143,11 @@ fn disabled_personality_bakes_default_and_preserves_catalog_approval_messages()
};
model.model_messages = Some(ModelMessages {
persistent_instructions: Some(String::new()),
tools: Some(ToolMessages {
send_user_message_async: Some(ToolMessage {
description: Some(String::new()),
}),
}),
instructions_template: Some("before {{ personality }} after".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: Some("default".to_string()),
@@ -156,6 +174,11 @@ fn disabled_personality_bakes_default_and_preserves_catalog_approval_messages()
updated.model_messages,
Some(ModelMessages {
persistent_instructions: Some(String::new()),
tools: Some(ToolMessages {
send_user_message_async: Some(ToolMessage {
description: Some(String::new()),
}),
}),
instructions_template: Some("before default after".to_string()),
instructions_variables: None,
approvals: Some(approvals),
@@ -185,6 +208,7 @@ fn disabled_personality_uses_plain_base_instructions_for_local_personality_model
updated.model_messages,
Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some(BASE_INSTRUCTIONS.to_string()),
instructions_variables: None,
approvals: None,
@@ -228,6 +252,7 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() {
let mut model = model_info_from_slug("unknown-model");
model.model_messages = Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some(instructions.to_string()),
instructions_variables: None,
approvals: None,
+92
View File
@@ -546,6 +546,8 @@ pub struct ModelMessages {
/// instructions; an empty string disables them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persistent_instructions: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<ToolMessages>,
pub instructions_template: Option<String>,
pub instructions_variables: Option<ModelInstructionsVariables>,
pub approvals: Option<ApprovalMessages>,
@@ -573,6 +575,22 @@ pub struct ConfirmationPolicies {
pub computer_use: Option<String>,
}
/// Model-owned messages for built-in tools.
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct ToolMessages {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub send_user_message_async: Option<ToolMessage>,
}
/// Model-owned messages for a built-in tool.
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct ToolMessage {
/// Missing or null uses the built-in description; an empty string leaves the description
/// empty without disabling the tool.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
/// Model-owned defaults for the context-window token-budget feature.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct ModelTokenBudgetConfig {
@@ -793,6 +811,7 @@ where
{
let messages = model.model_messages.get_or_insert(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -1002,6 +1021,7 @@ mod tests {
messages,
ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -1016,6 +1036,61 @@ mod tests {
);
}
#[test]
fn send_user_message_async_description_preserves_missing_null_and_empty_values() {
for (value, expected) in [
(serde_json::json!({}), None),
(serde_json::json!({"tools": null}), None),
(serde_json::json!({"tools": {}}), Some(None)),
(
serde_json::json!({"tools": {"send_user_message_async": null}}),
Some(None),
),
(
serde_json::json!({"tools": {"send_user_message_async": {}}}),
Some(Some(ToolMessage::default())),
),
(
serde_json::json!({"tools": {"send_user_message_async": {"description": null}}}),
Some(Some(ToolMessage::default())),
),
(
serde_json::json!({"tools": {"send_user_message_async": {"description": ""}}}),
Some(Some(ToolMessage {
description: Some(String::new()),
})),
),
(
serde_json::json!({"tools": {"send_user_message_async": {"description": "Catalog description"}}}),
Some(Some(ToolMessage {
description: Some("Catalog description".to_string()),
})),
),
] {
let messages: ModelMessages =
serde_json::from_value(value).expect("model messages should deserialize");
let serialized =
serde_json::to_value(&messages).expect("model messages should serialize");
assert_eq!(
(
messages.tools,
serialized
.get("tools")
.map(|tools| tools.get("send_user_message_async").cloned()),
),
(
expected.as_ref().map(|tool| ToolMessages {
send_user_message_async: tool.clone(),
}),
expected.map(|tool| tool.map(|tool| match tool.description {
Some(description) => serde_json::json!({"description": description}),
None => serde_json::json!({}),
})),
)
);
}
}
#[test]
fn approval_messages_preserve_missing_and_empty_values() {
let messages: ModelMessages = from_str(
@@ -1150,6 +1225,7 @@ mod tests {
messages,
ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -1243,6 +1319,7 @@ mod tests {
fn get_model_instructions_uses_template_when_placeholder_present() {
let model = test_model(Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some("Hello {{ personality }}".to_string()),
instructions_variables: Some(personality_variables()),
approvals: None,
@@ -1264,6 +1341,7 @@ mod tests {
fn get_model_instructions_strips_placeholder_with_incomplete_variables() {
let model = test_model(Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some("Hello\n{{ personality }}".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: None,
@@ -1290,6 +1368,7 @@ mod tests {
let model_no_personality = test_model(Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some("Hello\n{{ personality }}".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: None,
@@ -1327,6 +1406,7 @@ mod tests {
fn get_model_instructions_is_empty_when_template_is_missing() {
let model = test_model(Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: Some(ModelInstructionsVariables {
personality_default: None,
@@ -1374,6 +1454,7 @@ mod tests {
model.model_messages,
Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some("legacy instructions".to_string()),
instructions_variables: None,
approvals: None,
@@ -1424,6 +1505,7 @@ mod tests {
let response = ModelsResponse {
models: vec![test_model(Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some("before {{ personality }} after".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: Some("default".to_string()),
@@ -1453,6 +1535,11 @@ mod tests {
fn models_response_prefers_template_and_preserves_message_siblings() {
let messages = ModelMessages {
persistent_instructions: Some("Persistent catalog instructions".to_string()),
tools: Some(ToolMessages {
send_user_message_async: Some(ToolMessage {
description: Some("Catalog description".to_string()),
}),
}),
instructions_template: None,
instructions_variables: None,
approvals: Some(ApprovalMessages {
@@ -1519,6 +1606,11 @@ mod tests {
let canonical_messages = ModelMessages {
persistent_instructions: Some(String::new()),
tools: Some(ToolMessages {
send_user_message_async: Some(ToolMessage {
description: Some(String::new()),
}),
}),
instructions_template: Some("canonical instructions".to_string()),
instructions_variables: None,
approvals: None,