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 authored and copyberry committed 2026-08-29 03:44:01 +00:00
1 parent 03861e69ef
commit 3c062df036
18 files changed
+298 -20

No files matched your search

+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,