fix: include tool catalog in cache-inspect prefix hashing (#1818)

- Track tool catalog as a static layer in prompt inspection
- Include tools in cache-warmup request with tool_choice=none
- Ensure tool schema changes are visible to base-static-prefix diagnostics
- Factor test_tool helper for cache-inspect test coverage
This commit is contained in:
Hunter Bown
2026-05-26 16:37:53 -05:00
parent aa83446d6b
commit 99941d9d01
2 changed files with 119 additions and 27 deletions
+63 -1
View File
@@ -1125,6 +1125,23 @@ mod tests {
};
use serde_json::json;
fn test_tool(name: &str) -> Tool {
Tool {
tool_type: None,
name: name.to_string(),
description: format!("{name} test tool"),
input_schema: json!({
"type": "object",
"properties": {},
}),
allowed_callers: None,
defer_loading: Some(false),
input_examples: None,
strict: Some(true),
cache_control: None,
}
}
#[test]
fn tool_name_roundtrip_dot() {
let original = "multi_tool_use.parallel";
@@ -1810,6 +1827,49 @@ mod tests {
));
}
#[test]
fn prompt_inspect_tracks_tool_catalog_in_static_prefix_hash() {
let request = MessageRequest {
model: "deepseek-v4-pro".to_string(),
messages: vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Current task".to_string(),
cache_control: None,
}],
}],
max_tokens: 1024,
system: Some(SystemPrompt::Text("Base policy".to_string())),
tools: Some(vec![test_tool("read_file")]),
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort: Some("max".to_string()),
stream: None,
temperature: None,
top_p: None,
};
let first = inspect_prompt_for_request(&request);
let mut changed_tools = request.clone();
changed_tools.tools = Some(vec![test_tool("read_file"), test_tool("grep_files")]);
let second = inspect_prompt_for_request(&changed_tools);
assert!(
first.layers.iter().any(|layer| {
layer.name == "Tool catalog" && layer.stability.label() == "static"
})
);
assert_ne!(
first.base_static_prefix_hash, second.base_static_prefix_hash,
"tool schema changes must be visible to cache-inspect base prefix diagnostics"
);
assert_ne!(
first.full_request_prefix_hash, second.full_request_prefix_hash,
"tool schema changes must be visible to full reusable-prefix diagnostics"
);
}
#[test]
fn cache_warmup_request_reuses_stable_prefix_and_fixed_user_tail() {
let request = MessageRequest {
@@ -1835,7 +1895,7 @@ mod tests {
"Base policy\n\n<project_instructions source=\"AGENTS.md\">\nStable project rules\n</project_instructions>\n\n## Previous Session Relay\n\nDynamic relay"
.to_string(),
)),
tools: None,
tools: Some(vec![test_tool("read_file")]),
tool_choice: None,
metadata: None,
thinking: None,
@@ -1850,6 +1910,8 @@ mod tests {
assert_eq!(warmup.max_tokens, 8);
assert_eq!(warmup.temperature, Some(0.0));
assert_eq!(warmup.reasoning_effort.as_deref(), Some("max"));
assert_eq!(warmup.tools.as_ref().map(Vec::len), Some(1));
assert_eq!(warmup.tool_choice, Some(json!("none")));
assert_eq!(warmup.messages.len(), 2);
assert_eq!(warmup.messages[0].role, "assistant");
assert_eq!(warmup.messages[1].role, "user");
+56 -26
View File
@@ -438,6 +438,7 @@ pub(crate) fn build_cache_warmup_request(request: &MessageRequest) -> MessageReq
struct PromptBuilder<'a> {
system: Option<&'a SystemPrompt>,
messages: &'a [Message],
tools: Option<&'a [Tool]>,
model: &'a str,
reasoning_effort: Option<&'a str>,
}
@@ -447,6 +448,7 @@ impl<'a> PromptBuilder<'a> {
Self {
system: request.system.as_ref(),
messages: &request.messages,
tools: request.tools.as_deref(),
model: &request.model,
reasoning_effort: request.reasoning_effort.as_deref(),
}
@@ -485,12 +487,17 @@ impl<'a> PromptBuilder<'a> {
should_replay_reasoning_content(self.model, self.reasoning_effort),
true,
);
inspect_wire_messages(&messages)
inspect_wire_request(self.tools, &messages)
}
fn build_cache_warmup_request(self) -> MessageRequest {
let system = stable_system_prompt(self.system);
let mut messages = stable_history_messages(self.messages);
let tools = self
.tools
.filter(|tools| !tools.is_empty())
.map(<[Tool]>::to_vec);
let tool_choice = tools.as_ref().map(|_| json!("none"));
messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
@@ -504,8 +511,8 @@ impl<'a> PromptBuilder<'a> {
messages,
max_tokens: 8,
system,
tools: None,
tool_choice: None,
tools,
tool_choice,
metadata: None,
thinking: None,
reasoning_effort: self.reasoning_effort.map(str::to_string),
@@ -581,20 +588,19 @@ impl PromptLayerStability {
}
}
fn inspect_wire_messages(messages: &[Value]) -> PromptInspection {
fn inspect_wire_request(tools: Option<&[Tool]>, messages: &[Value]) -> PromptInspection {
let mut layers = Vec::new();
let mut base_static_prefix_parts = Vec::new();
let mut full_request_prefix_parts = Vec::new();
let mut start_index = 0;
for (index, message) in messages.iter().enumerate() {
if let Some(message) = messages.first() {
let role = message
.get("role")
.and_then(Value::as_str)
.unwrap_or("unknown");
let content = message_content_for_inspect(message);
let is_last = index + 1 == messages.len();
if index == 0 && role == "system" {
if role == "system" {
for (name, stability, body) in split_system_layers(&content) {
if stability == PromptLayerStability::Static {
base_static_prefix_parts.push(body.to_string());
@@ -604,27 +610,46 @@ fn inspect_wire_messages(messages: &[Value]) -> PromptInspection {
}
layers.push(prompt_layer(name, stability, body));
}
} else {
let stability = if (is_last && role == "user") || role == "tool" {
PromptLayerStability::Dynamic
} else {
PromptLayerStability::History
};
let name = if is_last && role == "user" {
"User task".to_string()
} else {
format!("Message #{index} {role}")
};
if stability != PromptLayerStability::Dynamic {
full_request_prefix_parts.push(content.clone());
}
let mut layer = prompt_layer(name, stability, &content);
layer.tool_result = tool_result_inspection_for_message(message);
layer.turn_meta = turn_meta_inspection_for_message(message);
layers.push(layer);
start_index = 1;
}
}
if let Some(tool_catalog) = tool_catalog_for_inspect(tools) {
base_static_prefix_parts.push(tool_catalog.clone());
full_request_prefix_parts.push(tool_catalog.clone());
layers.push(prompt_layer(
"Tool catalog".to_string(),
PromptLayerStability::Static,
&tool_catalog,
));
}
for (index, message) in messages.iter().enumerate().skip(start_index) {
let role = message
.get("role")
.and_then(Value::as_str)
.unwrap_or("unknown");
let content = message_content_for_inspect(message);
let is_last = index + 1 == messages.len();
let stability = if (is_last && role == "user") || role == "tool" {
PromptLayerStability::Dynamic
} else {
PromptLayerStability::History
};
let name = if is_last && role == "user" {
"User task".to_string()
} else {
format!("Message #{index} {role}")
};
if stability != PromptLayerStability::Dynamic {
full_request_prefix_parts.push(content.clone());
}
let mut layer = prompt_layer(name, stability, &content);
layer.tool_result = tool_result_inspection_for_message(message);
layer.turn_meta = turn_meta_inspection_for_message(message);
layers.push(layer);
}
let base_static_prefix = base_static_prefix_parts.join("\n");
let full_request_prefix = full_request_prefix_parts.join("\n");
@@ -635,6 +660,11 @@ fn inspect_wire_messages(messages: &[Value]) -> PromptInspection {
}
}
fn tool_catalog_for_inspect(tools: Option<&[Tool]>) -> Option<String> {
let tools = tools.filter(|tools| !tools.is_empty())?;
serde_json::to_string(&tools.iter().map(tool_to_chat).collect::<Vec<_>>()).ok()
}
fn message_content_for_inspect(message: &Value) -> String {
let mut parts = Vec::new();
if let Some(content) = message.get("content").and_then(Value::as_str)