From 79cd1f83b81661715a48385e04d24406fdadc0cd Mon Sep 17 00:00:00 2001 From: Arun Mahapatra Date: Sat, 20 Jun 2026 10:23:02 +0530 Subject: [PATCH 1/2] fix(gguf): use correct tokens_to_skip and sync previous_tokens on checkpoint restore - Fix checkpoint_tokens_skipped = cp.tokens.len() instead of cp.position to prevent overshooting verified prefix when matching checkpoints - Update state.previous_tokens on checkpoint restore to keep periodic checkpoint saves from storing stale token data --- Cargo.lock | 8 ++++---- crates/core/Cargo.toml | 2 +- crates/core/src/provider/gguf/checkpoint.rs | 2 +- crates/core/src/provider/gguf/model.rs | 1 + 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16ca50d..ee4f59d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1729,9 +1729,9 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "llama-cpp-2" -version = "0.1.146" +version = "0.1.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3b0f368c76cc0fe475e8257aeeec269e0d6569bd48b1f503efd0963fc3ee397" +checksum = "e82ec86fd73aaa7d8f4898cc4693410c217431506ba3237ea5c856127f49d84d" dependencies = [ "encoding_rs", "enumflags2", @@ -1743,9 +1743,9 @@ dependencies = [ [[package]] name = "llama-cpp-sys-2" -version = "0.1.146" +version = "0.1.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b291e4bc2d10c43cd8dec16d49b6104cb3cb125f596ec380a753a5db1d965dd" +checksum = "f67dab3ed2b68e4fc4a42471eac73e128ed2ee97bd10de66ddcc609dd5d838a0" dependencies = [ "bindgen", "cc", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index a193660..b07a5c7 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -36,7 +36,7 @@ tracing-subscriber.workspace = true notify.workspace = true minijinja = { version = "2.18.0", features = ["builtins", "json"] } minijinja-contrib = { version = "2.18.0", features = ["pycompat"] } -llama-cpp-2 = "=0.1.146" +llama-cpp-2 = "=0.1.150" [dev-dependencies] tempfile = { workspace = true, features = [] } diff --git a/crates/core/src/provider/gguf/checkpoint.rs b/crates/core/src/provider/gguf/checkpoint.rs index e943195..a3d4d3d 100644 --- a/crates/core/src/provider/gguf/checkpoint.rs +++ b/crates/core/src/provider/gguf/checkpoint.rs @@ -252,7 +252,7 @@ impl CheckpointManager { if priority > best_match_priority { best_match_priority = priority; checkpoint_restored = true; - checkpoint_tokens_skipped = cp.position; + checkpoint_tokens_skipped = cp.tokens.len(); restored_position = Some(cp.position as i32); best_match_transition = Some(cp.transition); } diff --git a/crates/core/src/provider/gguf/model.rs b/crates/core/src/provider/gguf/model.rs index dc0ecd5..4c04986 100644 --- a/crates/core/src/provider/gguf/model.rs +++ b/crates/core/src/provider/gguf/model.rs @@ -751,6 +751,7 @@ fn handle_request( ); if state.checkpoint_manager.restore(context, cp) { state.position = cp.position as i32; + state.previous_tokens = cp.tokens.clone(); debug!( "Checkpoint restored: skipping {} tokens, position now {}", cache_status.tokens_to_skip, state.position From 9560e75622eb2cd6655bdace647449ffae4f5890 Mon Sep 17 00:00:00 2001 From: Arun Mahapatra Date: Sat, 20 Jun 2026 17:54:18 +0530 Subject: [PATCH 2/2] fix(gguf): prefer longest checkpoint prefix, remove dead periodic code - Change cache_status() selection from highest priority to longest matching prefix, using priority only as tiebreaker for equal lengths. This means Turn 2 reuses the full Turn 1 checkpoint (including assistant response) instead of falling back to an older ToolCall checkpoint, avoiding unnecessary reprocessing. - Add tests: prefers_longer_prefix_over_higher_priority, same_length_uses_priority_tiebreaker - Remove dead periodic_interval/last_periodic_position fields and should_save_periodic method (periodic saves moved out of decode loop) --- crates/core/src/context.rs | 24 +++- crates/core/src/provider/gguf/checkpoint.rs | 133 ++++++++++++++------ crates/core/src/provider/gguf/model.rs | 64 ++++++---- crates/core/src/provider/test_provider.rs | 30 ++++- crates/core/src/session.rs | 116 ++++++++++++++++- crates/tools-fetch/src/lib.rs | 7 +- crates/tools-search/src/lib.rs | 2 +- 7 files changed, 302 insertions(+), 74 deletions(-) diff --git a/crates/core/src/context.rs b/crates/core/src/context.rs index 65ee38a..2583a99 100644 --- a/crates/core/src/context.rs +++ b/crates/core/src/context.rs @@ -428,9 +428,8 @@ impl Context { .sum() }; - self.messages = recent; - self.messages - .extend(summaries.into_iter().map(|m| (m, None))); + self.messages = summaries.into_iter().map(|m| (m, None)).collect(); + self.messages.extend(recent); let compacted_count = self.messages.len(); @@ -772,6 +771,11 @@ mod compact_tests { noop_when_empty = { vec![], true }, noop_when_under_threshold = { vec![msg_tuple(new_chat_msg(SenderType::User, "Hi"))], true }, compact_when_over_threshold = { vec![msg_tuple(new_chat_msg(SenderType::User, &"X".repeat(10000)))], false }, + compact_maintains_order = { vec![ + msg_tuple(new_chat_msg(SenderType::User, &"X".repeat(11000))), + msg_tuple(new_chat_msg(SenderType::User, &"Y".repeat(11000))), + msg_tuple(new_chat_msg(SenderType::User, &"Z".repeat(11000))), + ], false }, )] fn test_compact(messages: Vec<(ChatMessage, Option)>, expect_noop: bool) { let mut ctx = Context::new(10000); @@ -786,6 +790,20 @@ mod compact_tests { assert_eq!(result.compacted_messages, result.original_messages); } else { assert!(result.compacted_messages <= result.original_messages); + // Verify chronological order is maintained + let texts: Vec<&str> = ctx.messages.iter().map(|(m, _)| m.text.as_str()).collect(); + if texts.len() >= 3 { + assert!( + texts[0].starts_with('X'), + "Oldest message should be first after compaction, got: {}", + texts[0].chars().next().unwrap_or('?') + ); + assert!( + texts[texts.len() - 1].starts_with('Z'), + "Newest message should be last after compaction, got: {}", + texts[texts.len() - 1].chars().next().unwrap_or('?') + ); + } } } } diff --git a/crates/core/src/provider/gguf/checkpoint.rs b/crates/core/src/provider/gguf/checkpoint.rs index a3d4d3d..6eef703 100644 --- a/crates/core/src/provider/gguf/checkpoint.rs +++ b/crates/core/src/provider/gguf/checkpoint.rs @@ -119,10 +119,6 @@ pub struct CheckpointManager { is_hybrid: bool, flags: LlamaStateSeqFlags, first_checkpoint_size: Option, - /// Dynamic periodic checkpoint interval (calculated from n_ctx and max_checkpoints) - periodic_interval: usize, - /// Last position where a periodic checkpoint was saved - last_periodic_position: usize, } impl CheckpointManager { @@ -131,28 +127,20 @@ impl CheckpointManager { /// # Arguments /// * `max_checkpoints` - Maximum number of checkpoints to keep (FIFO eviction) /// * `is_hybrid` - Whether the model is hybrid/recurrent (uses PARTIAL_ONLY flag) - /// * `n_ctx` - Context window size (used to calculate periodic interval) - pub fn new(max_checkpoints: usize, is_hybrid: bool, n_ctx: usize) -> Self { + /// * `n_ctx` - Context window size + pub fn new(max_checkpoints: usize, is_hybrid: bool, _n_ctx: usize) -> Self { let flags = if is_hybrid { LlamaStateSeqFlags::PARTIAL_ONLY } else { LlamaStateSeqFlags::empty() }; - // Calculate periodic checkpoint interval - // Reserve 4 slots for transitions + 4 for request boundaries = 8 - // Remaining for periodic - let periodic_slots = max_checkpoints.saturating_sub(8).max(1); - let periodic_interval = n_ctx / periodic_slots; - Self { checkpoints: Vec::new(), max_checkpoints, is_hybrid, flags, first_checkpoint_size: None, - periodic_interval, - last_periodic_position: 0, } } @@ -223,7 +211,10 @@ impl CheckpointManager { }; } - // Hybrid: use checkpoint exact prefix matching with priority. + // Hybrid: use checkpoint exact prefix matching. + // Prefer longest prefix match (most tokens skipped = most efficient). + // Use priority as tiebreaker when lengths are equal. + let mut best_match_len: usize = 0; let mut best_match_priority: u8 = 0; let mut best_match_transition: Option = None; if !self.checkpoints.is_empty() && !tokens.is_empty() { @@ -238,21 +229,21 @@ impl CheckpointManager { }; if is_prefix { + let match_len = cp.tokens.len(); let priority = checkpoint_priority(cp.transition); debug!( "Checkpoint check: cp_position={}, cp_tokens_len={}, transition={:?}, priority={}, current_position={}", - cp.position, - cp.tokens.len(), - cp.transition, - priority, - current_position + cp.position, match_len, cp.transition, priority, current_position ); - // Pick highest priority match - if priority > best_match_priority { + // Pick longest prefix match; break ties by highest priority + if match_len > best_match_len + || (match_len == best_match_len && priority > best_match_priority) + { + best_match_len = match_len; best_match_priority = priority; checkpoint_restored = true; - checkpoint_tokens_skipped = cp.tokens.len(); + checkpoint_tokens_skipped = match_len; restored_position = Some(cp.position as i32); best_match_transition = Some(cp.transition); } @@ -307,21 +298,6 @@ impl CheckpointManager { } } - /// Checks if we should save a periodic checkpoint based on tokens processed. - /// - /// # Arguments - /// * `current_position` - Current position in the sequence - /// - /// # Returns - /// `true` if a periodic checkpoint should be saved - pub fn should_save_periodic(&self, current_position: usize) -> bool { - if self.periodic_interval == 0 { - return false; - } - let tokens_since_last = current_position.saturating_sub(self.last_periodic_position); - tokens_since_last >= self.periodic_interval - } - /// Saves a checkpoint at a specific transition point. /// /// # Arguments @@ -742,6 +718,87 @@ mod checkpoint_tests { assert_eq!(result.restored_position, Some(3)); } + #[test] + fn test_cache_status_prefers_longer_prefix_over_higher_priority() { + // ToolCall (3 tokens, pri 40) vs TurnEnd (5 tokens, pri 20). + // Both are prefixes of the 7-token request. + // Longest prefix should win: TurnEnd (5 tokens) beats ToolCall (3 tokens). + let checkpoints = vec![ + Checkpoint::new( + vec![token(1), token(2), token(3)], + vec![1, 2, 3], + 3, + TransitionType::ToolCall, + ), + Checkpoint::new( + vec![token(1), token(2), token(3), token(4), token(5)], + vec![1, 2, 3, 4, 5], + 5, + TransitionType::TurnEnd, + ), + ]; + let tokens = vec![ + token(1), + token(2), + token(3), + token(4), + token(5), + token(6), + token(7), + ]; + + let mgr = add_checkpoints(CheckpointManager::new(10, true, 4096), checkpoints); + let result = mgr.cache_status(true, 0, &tokens, &[]); + + assert!(result.checkpoint_restored); + assert_eq!( + result.tokens_to_skip, 5, + "TurnEnd (5 tokens) should be preferred over ToolCall (3 tokens) due to longer prefix" + ); + assert_eq!(result.restored_position, Some(5)); + assert_eq!(result.restored_transition, Some(TransitionType::TurnEnd)); + } + + #[test] + fn test_cache_status_same_length_uses_priority_tiebreaker() { + // Periodic (3 tokens, pri 10) vs TurnStart (3 tokens, pri 50). + // Same prefix length, so higher priority (TurnStart) wins. + let checkpoints = vec![ + Checkpoint::new( + vec![token(1), token(2), token(3)], + vec![1, 2, 3], + 3, + TransitionType::Periodic, + ), + Checkpoint::new( + vec![token(1), token(2), token(3)], + vec![1, 2, 3], + 3, + TransitionType::TurnStart, + ), + ]; + let tokens = vec![ + token(1), + token(2), + token(3), + token(4), + token(5), + token(6), + token(7), + ]; + + let mgr = add_checkpoints(CheckpointManager::new(10, true, 4096), checkpoints); + let result = mgr.cache_status(true, 0, &tokens, &[]); + + assert!(result.checkpoint_restored); + assert_eq!(result.tokens_to_skip, 3); + assert_eq!( + result.restored_transition, + Some(TransitionType::TurnStart), + "TurnStart (pri 50) should be preferred over Periodic (pri 10) when lengths are equal" + ); + } + // Helper function to add checkpoints to manager for testing fn add_checkpoints( mut mgr: CheckpointManager, diff --git a/crates/core/src/provider/gguf/model.rs b/crates/core/src/provider/gguf/model.rs index 4c04986..18cb39b 100644 --- a/crates/core/src/provider/gguf/model.rs +++ b/crates/core/src/provider/gguf/model.rs @@ -840,6 +840,36 @@ fn handle_request( } // Use decode with memory recovery if let Err(_e) = decode_with_recovery(context, &mut batch, state) { + // If OOM cleared state (position=0), retry all tokens from scratch + if state.position == 0 { + let mut retry_ok = true; + let mut retry_pos = 0i32; + for retry_chunk in tokens.chunks(state.n_batch) { + batch.clear(); + for (i, &token) in retry_chunk.iter().enumerate() { + if let Err(_e) = + batch.add(token, retry_pos, &[0], i == retry_chunk.len() - 1) + { + retry_ok = false; + break; + } + retry_pos += 1; + } + if !retry_ok { + break; + } + if let Err(e) = context.decode(&mut batch) { + tracing::error!("decode failed during retry after OOM: {}", e); + retry_ok = false; + break; + } + } + if retry_ok { + in_token_count = retry_pos; + tracing::info!("decode succeeded after OOM recovery + full retry"); + break; + } + } // Memory was trimmed - send completion with trim signal instead of error // This allows generation to continue gracefully let _ = response_tx.blocking_send(Ok(Completion::Response(CompletionResponse { @@ -872,30 +902,6 @@ fn handle_request( }))); return; } - - // Save periodic checkpoint if we've crossed the interval - if state.is_hybrid - && state - .checkpoint_manager - .should_save_periodic(in_token_count as usize) - { - // tokens contains new tokens for this request - // in_token_count = previous_position + tokens_processed_in_this_request - // We need to save all tokens up to current position = previous_tokens + processed new tokens - let tokens_processed = (in_token_count - state.position) as usize; - let tokens_for_checkpoint = state - .previous_tokens - .iter() - .chain(tokens.iter().take(tokens_processed)) - .cloned() - .collect::>(); - state.checkpoint_manager.save_at_transition( - context, - tokens_for_checkpoint, - in_token_count as usize, - TransitionType::Periodic, - ); - } } } else { debug!( @@ -907,6 +913,16 @@ fn handle_request( state.position = in_token_count; state.previous_tokens = tokens.clone(); + // Save periodic checkpoint after all prompt tokens processed + if state.is_hybrid && !had_overflow && state.position > 0 { + state.checkpoint_manager.save_at_transition( + context, + tokens.clone(), + state.position as usize, + TransitionType::Periodic, + ); + } + debug!( "After prompt: position={}, batch_n_tokens={}, is_hybrid={}, tokens_to_process.len()={}", state.position, diff --git a/crates/core/src/provider/test_provider.rs b/crates/core/src/provider/test_provider.rs index c7766e1..a1946cc 100644 --- a/crates/core/src/provider/test_provider.rs +++ b/crates/core/src/provider/test_provider.rs @@ -18,10 +18,14 @@ use std::sync::atomic::{AtomicBool, Ordering}; /// The `response_mode` setting controls what kind of response it generates: /// - `""` (default): A simple "Hello world" streaming text response. /// - `"tool_call"`: A response containing a tool call to `mock_tool`. +/// - `"persistent_tool_call"`: Always returns a tool call on every invocation, +/// ignoring whether the last message is a tool result. Used to test tool-loop +/// dedup logic. /// - `"error"`: An error response. /// /// It also responds with a final answer if the last message in the chat history -/// is from a tool, simulating a complete tool-use cycle. +/// is from a tool, simulating a complete tool-use cycle (unless overridden by +/// `persistent_tool_call`). #[derive(Debug)] pub struct TestProviderModel { config: ModelConfig, @@ -66,8 +70,26 @@ impl CompletionModel for TestProviderModel { ) -> BoxStream<'static, Result> { let response_mode: String = self.config.get_setting("response_mode").unwrap_or_default(); - // If the last message is a tool result, always return the "final answer". - if let Some(last_msg) = messages.last() + // When persistent_tool_call mode sees no tools were passed (session + // stripped them after dedup triggered), return a text response so the + // tool loop can exit naturally. + if response_mode == "persistent_tool_call" && _tools.is_none() { + let response = Completion::Response(CompletionResponse { + text: "Final answer based on available information".to_string(), + thought: None, + tool_calls: None, + finish_reason: Some("stop".to_string()), + raw_chunk: None, + }); + let metrics = Completion::Metrics(CompletionMetrics::default()); + let stream = stream::iter(vec![Ok(response), Ok(metrics)]); + return Box::pin(stream); + } + + // If the last message is a tool result, return the "final answer" — + // unless persistent_tool_call mode is set (for testing tool-loop dedup). + if response_mode != "persistent_tool_call" + && let Some(last_msg) = messages.last() && last_msg.sender == SenderType::Tool { let response = Completion::Response(CompletionResponse { @@ -87,7 +109,7 @@ impl CompletionModel for TestProviderModel { let stream = stream::once(async { Err(anyhow!("TestProviderModel error")) }); Box::pin(stream) } - "tool_call" => { + "tool_call" | "persistent_tool_call" => { let tool_call = ToolCall { id: "c1".to_string(), name: "mock_tool".to_string(), diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index d7c27b2..69d579e 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -239,6 +239,8 @@ impl Session { let mut iteration = 0; let mut pending_tool_calls: Vec = Vec::new(); + let mut previous_tool_calls: Vec = Vec::new(); + let mut dedup_triggered = false; loop { // Check compaction @@ -298,7 +300,9 @@ impl Session { effective_settings.insert("enable_thinking".to_string(), enabled.to_string()); } - let tool_slice = if self.config.tool_execution_enabled && !self.config.tools.is_empty() { + let tool_slice: Vec> = if dedup_triggered { + Vec::new() + } else if self.config.tool_execution_enabled && !self.config.tools.is_empty() { self.config.tools.clone() } else { Vec::new() @@ -311,6 +315,13 @@ impl Session { cancel_token.clone() ).await; + debug!( + "Tool loop iteration {}: sending {} messages to model; last user msg: {:?}", + iteration, + messages_to_send.len(), + messages_to_send.iter().rev().find(|m| m.sender == SenderType::User).map(|m| m.text.chars().take(100).collect::()) + ); + pending_tool_calls.clear(); let mut assistant_text = String::new(); let mut assistant_thought = String::new(); @@ -359,6 +370,24 @@ impl Session { }; let _ = self.add_message(assistant_msg); + // Check for repeated identical tool calls (same name + arguments) + if !pending_tool_calls.is_empty() + && !previous_tool_calls.is_empty() + && pending_tool_calls.len() == previous_tool_calls.len() + && pending_tool_calls.iter().zip(previous_tool_calls.iter()).all(|(a, b)| a.name == b.name && a.arguments == b.arguments) + { + warn!("Same tool calls repeated ({}), synthesizing response", + pending_tool_calls.iter().map(|t| t.name.clone()).collect::>().join(", ")); + let context_msg = ChatMessage { + sender: SenderType::Tool, + text: "Skipped repeated search query. Use a different query, or create an answer from above results. If needed, use `fetch` tool to get more details for the result url.".to_string(), + ..Default::default() + }; + let _ = self.add_message(context_msg); + dedup_triggered = true; + continue; + } + // No more tool calls - exit loop if pending_tool_calls.is_empty() { break; @@ -370,6 +399,8 @@ impl Session { break; } + previous_tool_calls = pending_tool_calls.clone(); + yield Ok(SessionEvent::ToolStart { calls: pending_tool_calls.clone(), }); @@ -969,4 +1000,87 @@ mod tool_tests { Ok(()) } + + #[tokio::test] + async fn test_repeated_tool_call_dedup_synthesizes_response() -> Result<()> { + let tool: Arc = Arc::new(TestTool { + name: "mock_tool".to_string(), + should_error: false, + }); + + let mut settings: HashMap = HashMap::new(); + settings.insert( + "response_mode".to_string(), + Value::String("persistent_tool_call".to_string()), + ); + + let model_config = ModelConfig { + key: "test".to_string(), + name: "Test".to_string(), + provider: ModelProvider::Test, + settings, + }; + + let session_config = SessionConfig { + tools: vec![tool], + tool_execution_enabled: true, + ..Default::default() + }; + + let mut session = Session::new(model_config, session_config)?; + session.add_message(new_chat_msg(SenderType::User, "test"))?; + + let stream = session + .generate(HashMap::new(), CancellationToken::new()) + .await?; + + let events: Vec = stream.try_collect().await?; + + // With persistent_tool_call, the model returns identical tool calls. + // The dedup check should trigger on the second iteration, skipping + // tool execution and continuing without tools to synthesize a text response. + let tool_end_events: Vec<_> = events + .iter() + .filter(|e| matches!(e, SessionEvent::ToolEnd { .. })) + .collect(); + + assert_eq!( + tool_end_events.len(), + 1, + "Tool should execute only once before dedup triggers continue" + ); + + // The dedup context message should be in the conversation + let messages = session.messages(); + let has_dedup_msg = messages.iter().any(|m| { + m.sender == SenderType::Tool && m.text.contains("Skipped repeated search query") + }); + assert!( + has_dedup_msg, + "Dedup context message should be added to conversation" + ); + + // A final text response should have been synthesized (not an abrupt error) + let synthesized_text: String = events + .iter() + .filter_map(|e| match e { + SessionEvent::Token(Completion::Response(r)) + if !r.text.is_empty() && r.tool_calls.is_none() => + { + Some(r.text.clone()) + } + _ => None, + }) + .collect(); + assert!( + !synthesized_text.is_empty(), + "Should have synthesized a text response after dedup" + ); + assert!( + synthesized_text.contains("Final answer"), + "Synthesized response should contain the final answer text" + ); + + Ok(()) + } } diff --git a/crates/tools-fetch/src/lib.rs b/crates/tools-fetch/src/lib.rs index 669008b..b87c2d8 100644 --- a/crates/tools-fetch/src/lib.rs +++ b/crates/tools-fetch/src/lib.rs @@ -106,7 +106,8 @@ impl Tool for FetchTool { } fn description(&self) -> String { - "Fetches a URL and extracts readable content as markdown".to_string() + "Fetches a URL content as readable markdown. Use to deep dive into search results." + .to_string() } fn parameters(&self) -> Value { @@ -115,11 +116,11 @@ impl Tool for FetchTool { "properties": { "url": { "type": "string", - "description": "The URL to fetch" + "description": "A valid URL to fetch. Ensure URLs are from valid source e.g., from user input or from `search` tool results." }, "search_text": { "type": "string", - "description": "Optional text to search for. If provided, returns only the surrounding context (3 lines before/after matching text) instead of full content." + "description": "Optional. If provided, return only the surrounding context (3 lines before/after matching text) instead of full content." } }, "required": ["url"] diff --git a/crates/tools-search/src/lib.rs b/crates/tools-search/src/lib.rs index 42da3b8..a1c876c 100644 --- a/crates/tools-search/src/lib.rs +++ b/crates/tools-search/src/lib.rs @@ -81,7 +81,7 @@ impl Tool for SearchTool { }, "categories": { "type": "string", - "description": "Comma-separated categories: general, images, videos, news, map, music, it, science, files, social_media" + "description": "Optional. Comma-separated, allowed values: general, images, videos, news, map, music, it, science, files, social_media" }, }, "required": ["query"]