From 99941d9d01cca9e9854bc72fa8b1876ff1268d4b Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:37:53 -0500 Subject: [PATCH] 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 --- crates/tui/src/client.rs | 64 ++++++++++++++++++++++++++- crates/tui/src/client/chat.rs | 82 ++++++++++++++++++++++++----------- 2 files changed, 119 insertions(+), 27 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 025a8344..80a6a009 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -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\nStable project rules\n\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"); diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index dd6c37a4..656330fc 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -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 { + let tools = tools.filter(|tools| !tools.is_empty())?; + serde_json::to_string(&tools.iter().map(tool_to_chat).collect::>()).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)