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:
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user