From 57a7e884ab16a39834c3f4625220d02aee42f136 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Tue, 14 Apr 2026 21:29:20 -0500 Subject: [PATCH] Prototype TinFoil-backed web search flow Stage a proof-of-concept that routes Maple web search through TinFoil while preserving classifier gating and a legacy fallback path for later iteration. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/web/responses/handlers.rs | 60 +--- src/web/responses/prompts.rs | 205 +++++++++---- src/web/responses/tools.rs | 486 ++++++++++++++++++++++++++++++- tinfoil-proxy/dist/tinfoil-proxy | Bin 24838306 -> 24641720 bytes tinfoil-proxy/main.go | 124 +++++++- tinfoil-proxy/main_test.go | 82 ++++++ 6 files changed, 836 insertions(+), 121 deletions(-) diff --git a/src/web/responses/handlers.rs b/src/web/responses/handlers.rs index d45f6028..12b627ea 100644 --- a/src/web/responses/handlers.rs +++ b/src/web/responses/handlers.rs @@ -1320,13 +1320,13 @@ fn is_web_search_enabled(tools: &Option) -> bool { /// /// Flow: /// 1. Classify intent: chat vs web_search -/// 2. If web_search: extract query and execute tool +/// 2. If web_search: draft the search-subagent request and execute tool /// 3. Send ToolCall event to streams /// 4. Send ToolOutput event to streams (always, even on error) /// 5. Send persistence command via dedicated channel and wait for acknowledgment /// /// Tool execution is best-effort: intent classification uses gpt-oss-120b and -/// query extraction uses llama3-3-70b. +/// web search execution prefers TinFoil web search with Brave/Kagi fallback. struct ToolChannels<'a> { client: &'a mpsc::Sender, storage: &'a mpsc::Sender, @@ -1428,51 +1428,16 @@ async fn classify_and_execute_tools( if intent == "web_search" { debug!("User message classified as web_search, executing tool"); - // Extract search query with conversation history for context - let query_request = prompts::build_query_extraction_request(prompt_messages, &user_text); - let billing_context = crate::web::openai::BillingContext::new( - crate::web::openai_auth::AuthMethod::Jwt, - prompts::QUERY_EXTRACTOR_MODEL.to_string(), - ); - - let search_query = match get_chat_completion_response( + let web_search_context = tools::WebSearchExecutionContext { state, user, - query_request, - &headers, - billing_context, - ) - .await - { - Ok(mut completion) => match completion.stream.recv().await { - Some(crate::web::openai::CompletionChunk::FullResponse(response_json)) => { - if let Some(query) = response_json - .get("choices") - .and_then(|c| c.get(0)) - .and_then(|c| c.get("message")) - .and_then(|m| m.get("content")) - .and_then(|c| c.as_str()) - { - let query = query.trim().to_string(); - trace!("Extracted search query: {}", query); - debug!("Search query extracted successfully"); - query - } else { - warn!("Failed to extract query, using original message"); - user_text.clone() - } - } - _ => { - warn!("Unexpected query extraction response, using original message"); - user_text.clone() - } - }, - Err(e) => { - warn!("Query extraction failed, using original message: {:?}", e); - user_text.clone() - } + conversation_history: prompt_messages, + user_message: &user_text, }; + let search_query = tools::prepare_web_search_query(&web_search_context).await; + trace!("Prepared web search query: {}", search_query); + // Generate UUIDs for tool_call and tool_output let tool_call_id = Uuid::new_v4(); let tool_output_id = Uuid::new_v4(); @@ -1523,6 +1488,7 @@ async fn classify_and_execute_tools( let tool_output = match tools::execute_tool( "web_search", &tool_arguments, + Some(&web_search_context), state.brave_client.as_ref(), state.kagi_client.as_ref(), ) @@ -2051,11 +2017,11 @@ async fn create_response_stream( // Run phases 5-6 with cancellation support tokio::select! { _ = async { - // Phase 5: Classify intent and execute tools (if tool_choice allows it AND web_search is enabled AND Kagi client available) + // Phase 5: Classify intent and execute tools (if tool_choice allows tools, web search is enabled, and at least one search backend is available) let tools_executed = if is_tool_choice_allowed(&orchestrator_body.tool_choice) && is_web_search_enabled(&orchestrator_body.tools) - && orchestrator_state.kagi_client.is_some() { - debug!("Orchestrator: tool_choice allows tools, web search enabled, and Kagi client available, proceeding with classification"); + && tools::has_web_search_backend(&orchestrator_state) { + debug!("Orchestrator: tool_choice allows tools, web search is enabled, and a search backend is available, proceeding with classification"); let prepared_for_tools = PreparedRequest { user_key, @@ -2100,7 +2066,7 @@ async fn create_response_stream( } } } else { - debug!("Orchestrator: Web search tool not enabled or Kagi client not available, skipping classification"); + debug!("Orchestrator: tool execution disabled or no search backend available, skipping classification"); drop(rx_tool_ack); false }; diff --git a/src/web/responses/prompts.rs b/src/web/responses/prompts.rs index 6d4923ff..03140e44 100644 --- a/src/web/responses/prompts.rs +++ b/src/web/responses/prompts.rs @@ -9,6 +9,10 @@ pub const INTENT_CLASSIFIER_MODEL: &str = "gpt-oss-120b"; pub const INTENT_CLASSIFIER_MAX_TOKENS: i32 = 250; pub const QUERY_EXTRACTOR_MODEL: &str = "llama3-3-70b"; pub const QUERY_EXTRACTOR_MAX_TOKENS: i32 = 50; +pub const WEB_SEARCH_PROMPT_DRAFTER_MODEL: &str = "llama3-3-70b"; +pub const WEB_SEARCH_PROMPT_DRAFTER_MAX_TOKENS: i32 = 120; +pub const WEB_SEARCH_SUBAGENT_MODEL: &str = "gemma4-31b"; +pub const WEB_SEARCH_SUBAGENT_MAX_TOKENS: i32 = 1200; /// System prompt for intent classification /// @@ -35,6 +39,55 @@ Examples: - \"Tell me about the latest SpaceX launch\" → latest SpaceX launch - After discussing \"iPhone 15\", user asks \"when was it released?\" → iPhone 15 release date"; +/// System prompt for drafting the request sent to the web search subagent. +pub const WEB_SEARCH_PROMPT_DRAFTER_PROMPT: &str = "\ +Rewrite the user's request into a focused brief for an internal web-search subagent. +Use the conversation history to resolve references and ambiguity. +Return only the brief to send to the search subagent. +The brief should state what factual information to gather and preserve any important constraints, entities, and time sensitivity. +Do not answer the question yourself. +Do not include citations, URLs, bullet lists, or conversational filler."; + +/// System prompt for the internal web search subagent +/// +/// This prompt instructs the LLM to use web search and return a concise, +/// grounded search report for the downstream conversational model. +pub const WEB_SEARCH_SUBAGENT_PROMPT: &str = "\ +You are an internal web search subagent for another language model. +Use web search to gather current, relevant, factual information that helps answer the user's latest question. +Return a concise search report for a downstream assistant, not a direct conversational reply to the end user. +Prioritize reliable and recent sources when the question is time-sensitive. +Do not include a 'Sources:' section or raw URLs in the body; source links are attached separately. +If the web results are incomplete, conflicting, or uncertain, say so briefly."; + +fn format_recent_conversation_history(conversation_history: &[Value]) -> String { + if conversation_history.is_empty() { + return String::new(); + } + + let formatted_messages: Vec = conversation_history + .iter() + .rev() + .take(6) + .rev() + .filter_map(|msg| { + let role = msg.get("role")?.as_str()?; + let content = extract_text_from_content(msg.get("content")?); + let truncated_content: String = content.chars().take(200).collect(); + Some(format!("{}: {}", role, truncated_content)) + }) + .collect(); + + if formatted_messages.is_empty() { + String::new() + } else { + format!( + "Conversation history:\n{}\n\n", + formatted_messages.join("\n") + ) + } +} + /// Build a chat completion request for intent classification /// /// Uses a fast, cheap model (gpt-oss-120b) with temperature=0 for deterministic results. @@ -49,32 +102,7 @@ pub fn build_intent_classification_request( conversation_history: &[Value], user_message: &str, ) -> Value { - // Format conversation history as text for context - let history_text = if !conversation_history.is_empty() { - let formatted_messages: Vec = conversation_history - .iter() - .rev() - .take(6) - .rev() - .filter_map(|msg| { - let role = msg.get("role")?.as_str()?; - let content = extract_text_from_content(msg.get("content")?); - let truncated_content: String = content.chars().take(200).collect(); - Some(format!("{}: {}", role, truncated_content)) - }) - .collect(); - - if formatted_messages.is_empty() { - String::new() - } else { - format!( - "Conversation history:\n{}\n\n", - formatted_messages.join("\n") - ) - } - } else { - String::new() - }; + let history_text = format_recent_conversation_history(conversation_history); // Build single user message with history + current query let user_prompt = format!("{}Current user query: {}", history_text, user_message); @@ -125,32 +153,7 @@ fn extract_text_from_content(content: &Value) -> String { /// # Returns /// A JSON request ready to be sent to `get_chat_completion_response` pub fn build_query_extraction_request(conversation_history: &[Value], user_message: &str) -> Value { - // Format conversation history as text for context - let history_text = if !conversation_history.is_empty() { - let formatted_messages: Vec = conversation_history - .iter() - .rev() - .take(6) - .rev() - .filter_map(|msg| { - let role = msg.get("role")?.as_str()?; - let content = extract_text_from_content(msg.get("content")?); - let truncated_content: String = content.chars().take(200).collect(); - Some(format!("{}: {}", role, truncated_content)) - }) - .collect(); - - if formatted_messages.is_empty() { - String::new() - } else { - format!( - "Conversation history:\n{}\n\n", - formatted_messages.join("\n") - ) - } - } else { - String::new() - }; + let history_text = format_recent_conversation_history(conversation_history); // Build single user message with history + current query let user_prompt = format!("{}Current user question: {}", history_text, user_message); @@ -173,6 +176,56 @@ pub fn build_query_extraction_request(conversation_history: &[Value], user_messa }) } +/// Build a chat completion request for drafting the web search subagent prompt. +pub fn build_web_search_prompt_drafting_request( + conversation_history: &[Value], + user_message: &str, +) -> Value { + let history_text = format_recent_conversation_history(conversation_history); + let user_prompt = format!("{}Current user question: {}", history_text, user_message); + + json!({ + "model": WEB_SEARCH_PROMPT_DRAFTER_MODEL, + "messages": [ + { + "role": "system", + "content": WEB_SEARCH_PROMPT_DRAFTER_PROMPT + }, + { + "role": "user", + "content": user_prompt + } + ], + "temperature": 0.0, + "max_tokens": WEB_SEARCH_PROMPT_DRAFTER_MAX_TOKENS, + "stream": false + }) +} + +/// Build a chat completion request for the internal web search subagent +/// +/// Uses gemma4-31b with `web_search_options` enabled to synthesize search +/// results that can be injected back into the main conversational model. +pub fn build_web_search_subagent_request(subagent_prompt: &str) -> Value { + json!({ + "model": WEB_SEARCH_SUBAGENT_MODEL, + "messages": [ + { + "role": "system", + "content": WEB_SEARCH_SUBAGENT_PROMPT + }, + { + "role": "user", + "content": subagent_prompt + } + ], + "temperature": 0.0, + "max_tokens": WEB_SEARCH_SUBAGENT_MAX_TOKENS, + "web_search_options": {}, + "stream": false + }) +} + #[cfg(test)] mod tests { use super::*; @@ -309,6 +362,52 @@ mod tests { assert!(user_content.contains("Current user question: when was it released?")); } + #[test] + fn test_build_web_search_prompt_drafting_request() { + let history = vec![ + json!({"role": "user", "content": "Tell me about SpaceX"}), + json!({"role": "assistant", "content": "What would you like to know?"}), + ]; + + let request = + build_web_search_prompt_drafting_request(&history, "what is its latest revenue?"); + + assert_eq!(request["model"], WEB_SEARCH_PROMPT_DRAFTER_MODEL); + assert_eq!(request["max_tokens"], WEB_SEARCH_PROMPT_DRAFTER_MAX_TOKENS); + assert_eq!(request["stream"], false); + + let messages = request["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["role"], "system"); + assert_eq!(messages[1]["role"], "user"); + + let user_content = messages[1]["content"].as_str().unwrap(); + assert!(user_content.contains("Conversation history:")); + assert!(user_content.contains("Tell me about SpaceX")); + assert!(user_content.contains("Current user question: what is its latest revenue?")); + } + + #[test] + fn test_build_web_search_subagent_request() { + let request = build_web_search_subagent_request( + "Find SpaceX's latest annual revenue estimate and note the source of the figure.", + ); + + assert_eq!(request["model"], WEB_SEARCH_SUBAGENT_MODEL); + assert_eq!(request["max_tokens"], WEB_SEARCH_SUBAGENT_MAX_TOKENS); + assert_eq!(request["stream"], false); + assert_eq!(request["web_search_options"], json!({})); + + let messages = request["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["role"], "system"); + assert_eq!(messages[1]["role"], "user"); + assert_eq!( + messages[1]["content"], + "Find SpaceX's latest annual revenue estimate and note the source of the figure." + ); + } + #[test] fn test_prompts_contain_examples() { assert!(INTENT_CLASSIFIER_PROMPT.contains("web_search")); @@ -316,5 +415,7 @@ mod tests { assert!(INTENT_CLASSIFIER_PROMPT.contains("conversation history")); assert!(SEARCH_QUERY_EXTRACTOR_PROMPT.contains("Examples:")); + assert!(WEB_SEARCH_PROMPT_DRAFTER_PROMPT.contains("search subagent")); + assert!(WEB_SEARCH_SUBAGENT_PROMPT.contains("Do not include a 'Sources:' section")); } } diff --git a/src/web/responses/tools.rs b/src/web/responses/tools.rs index 3baff002..8edf096a 100644 --- a/src/web/responses/tools.rs +++ b/src/web/responses/tools.rs @@ -3,16 +3,108 @@ //! This module handles tool execution including web search, with a clean //! architecture that can be extended for additional tools in the future. +use super::prompts; use crate::brave::{BraveClient, SearchRequest as BraveSearchRequest}; use crate::kagi::{KagiClient, SearchRequest as KagiSearchRequest}; +use crate::models::users::User; +use crate::web::openai::{get_chat_completion_response, BillingContext, CompletionChunk}; +use crate::web::openai_auth::AuthMethod; +use crate::AppState; +use axum::http::HeaderMap; use serde_json::{json, Value}; +use std::collections::HashSet; use std::sync::Arc; use tracing::{debug, error, info, trace, warn}; -/// Execute web search using Brave or Kagi Search API +pub(crate) struct WebSearchExecutionContext<'a> { + pub state: &'a Arc, + pub user: &'a User, + pub conversation_history: &'a [Value], + pub user_message: &'a str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct UrlCitation { + title: String, + url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct BlockedSearch { + query: String, + reason: String, +} + +pub(crate) fn has_web_search_backend(state: &Arc) -> bool { + state.proxy_router.get_tinfoil_proxy().is_some() + || state.brave_client.is_some() + || state.kagi_client.is_some() +} + +pub(crate) async fn prepare_web_search_query(context: &WebSearchExecutionContext<'_>) -> String { + trace!( + "Preparing web search subagent query from user message: {}", + context.user_message + ); + + let request = prompts::build_web_search_prompt_drafting_request( + context.conversation_history, + context.user_message, + ); + let headers = HeaderMap::new(); + let billing_context = BillingContext::new( + AuthMethod::Jwt, + prompts::WEB_SEARCH_PROMPT_DRAFTER_MODEL.to_string(), + ); + + match get_chat_completion_response( + context.state, + context.user, + request, + &headers, + billing_context, + ) + .await + { + Ok(mut completion) => match completion.stream.recv().await { + Some(CompletionChunk::FullResponse(response_json)) => extract_completion_text( + response_json + .get("choices") + .and_then(|choices| choices.get(0)) + .and_then(|choice| choice.get("message")), + ) + .map(|query| query.trim().to_string()) + .filter(|query| !query.is_empty()) + .unwrap_or_else(|| { + warn!("Failed to prepare web search query, using original message"); + context.user_message.to_string() + }), + Some(CompletionChunk::Error(err)) => { + warn!( + "Web search prompt drafting returned an error, using original message: {}", + err + ); + context.user_message.to_string() + } + _ => { + warn!("Unexpected web search prompt drafting response, using original message"); + context.user_message.to_string() + } + }, + Err(e) => { + warn!( + "Web search prompt drafting failed, using original message: {:?}", + e + ); + context.user_message.to_string() + } + } +} + +/// Execute web search using Brave or Kagi Search API. /// -/// Prefers Brave if available, falls back to Kagi if Brave is not configured. -/// Requires at least one client to be provided (initialized at startup with connection pooling). +/// This remains the fallback path when TinFoil web search is unavailable or +/// fails. Prefers Brave if available, then falls back to Kagi. pub async fn execute_web_search( query: &str, brave_client: Option<&Arc>, @@ -32,6 +124,133 @@ pub async fn execute_web_search( } } +async fn execute_web_search_with_context( + context: &WebSearchExecutionContext<'_>, + query: &str, + brave_client: Option<&Arc>, + kagi_client: Option<&Arc>, +) -> Result { + trace!("Executing context-aware web search for query: {}", query); + + let mut tinfoil_error = None; + + if context.state.proxy_router.get_tinfoil_proxy().is_some() { + match execute_tinfoil_web_search(context, query).await { + Ok(output) => return Ok(output), + Err(err) => { + warn!( + "TinFoil web search failed, falling back to legacy providers if available: {}", + err + ); + tinfoil_error = Some(err); + } + } + } + + if brave_client.is_some() || kagi_client.is_some() { + let fallback_query = extract_fallback_search_query(context).await; + return execute_web_search(&fallback_query, brave_client, kagi_client).await; + } + + if let Some(err) = tinfoil_error { + Err(err) + } else { + error!("No search client configured"); + Err("No search client configured".to_string()) + } +} + +async fn execute_tinfoil_web_search( + context: &WebSearchExecutionContext<'_>, + subagent_prompt: &str, +) -> Result { + trace!( + "Executing TinFoil web search with prepared subagent prompt: {}", + subagent_prompt + ); + + let request = prompts::build_web_search_subagent_request(subagent_prompt); + let headers = HeaderMap::new(); + let billing_context = BillingContext::new( + AuthMethod::Jwt, + prompts::WEB_SEARCH_SUBAGENT_MODEL.to_string(), + ); + + let mut completion = get_chat_completion_response( + context.state, + context.user, + request, + &headers, + billing_context, + ) + .await + .map_err(|e| format!("TinFoil web search request failed: {:?}", e))?; + + match completion.stream.recv().await { + Some(CompletionChunk::FullResponse(response_json)) => { + trace!( + "TinFoil web search response: {}", + serde_json::to_string_pretty(&response_json) + .unwrap_or_else(|_| "failed to serialize".to_string()) + ); + format_tinfoil_web_search_response(&response_json) + } + Some(CompletionChunk::Error(err)) => Err(format!("TinFoil web search error: {}", err)), + _ => Err("TinFoil web search returned unexpected response".to_string()), + } +} + +async fn extract_fallback_search_query(context: &WebSearchExecutionContext<'_>) -> String { + let query_request = + prompts::build_query_extraction_request(context.conversation_history, context.user_message); + let headers = HeaderMap::new(); + let billing_context = + BillingContext::new(AuthMethod::Jwt, prompts::QUERY_EXTRACTOR_MODEL.to_string()); + + match get_chat_completion_response( + context.state, + context.user, + query_request, + &headers, + billing_context, + ) + .await + { + Ok(mut completion) => match completion.stream.recv().await { + Some(CompletionChunk::FullResponse(response_json)) => extract_completion_text( + response_json + .get("choices") + .and_then(|choices| choices.get(0)) + .and_then(|choice| choice.get("message")), + ) + .map(|query| query.trim().to_string()) + .filter(|query| !query.is_empty()) + .unwrap_or_else(|| { + warn!("Failed to extract fallback search query, using original message"); + context.user_message.to_string() + }), + Some(CompletionChunk::Error(err)) => { + warn!( + "Fallback query extraction returned an error, using original message: {}", + err + ); + context.user_message.to_string() + } + _ => { + warn!("Unexpected fallback query extraction response, using original message"); + context.user_message.to_string() + } + }, + Err(e) => { + warn!( + "Fallback query extraction failed, using original message: {:?}", + e + ); + context.user_message.to_string() + } + } +} + /// Execute web search using Brave Search API async fn execute_brave_search(query: &str, client: &Arc) -> Result { trace!("Executing Brave search for query: {}", query); @@ -231,6 +450,167 @@ async fn execute_kagi_search(query: &str, client: &Arc) -> Result Result { + let message = response_json + .get("choices") + .and_then(|choices| choices.get(0)) + .and_then(|choice| choice.get("message")) + .ok_or_else(|| "TinFoil web search returned no assistant message".to_string())?; + + let content = + sanitize_tinfoil_summary(&extract_completion_text(Some(message)).unwrap_or_default()); + let blocked_searches = extract_blocked_searches(message); + let citations = extract_url_citations(message); + + let mut sections = Vec::new(); + + if !content.trim().is_empty() { + sections.push(format!("Search Summary:\n\n{}", content.trim())); + } + + if !blocked_searches.is_empty() { + let blocked = blocked_searches + .iter() + .map(|blocked| format!("- {} ({})", blocked.query, blocked.reason)) + .collect::>() + .join("\n"); + sections.push(format!("Blocked Searches:\n{}", blocked)); + } + + if !citations.is_empty() { + let sources = citations + .iter() + .enumerate() + .map(|(index, citation)| { + format!("{}. {} — {}", index + 1, citation.title, citation.url) + }) + .collect::>() + .join("\n"); + sections.push(format!("Sources:\n{}", sources)); + } + + if sections.is_empty() { + Err("TinFoil web search returned no content".to_string()) + } else { + Ok(sections.join("\n\n")) + } +} + +fn sanitize_tinfoil_summary(content: &str) -> String { + let mut cleaned_lines = Vec::new(); + + for line in content.lines() { + if is_sources_heading(line) { + break; + } + + cleaned_lines.push(line); + } + + cleaned_lines.join("\n").trim().to_string() +} + +fn is_sources_heading(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.eq_ignore_ascii_case("sources:") + || trimmed + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("sources:")) +} + +fn extract_completion_text(message: Option<&Value>) -> Option { + let content = message?.get("content")?; + + match content { + Value::String(text) => Some(text.clone()), + Value::Array(parts) => { + let joined = parts + .iter() + .filter_map(|part| { + part.get("text") + .and_then(|text| text.as_str()) + .or_else(|| part.get("content").and_then(|text| text.as_str())) + .map(|text| text.to_string()) + }) + .collect::>() + .join("\n"); + + if joined.trim().is_empty() { + None + } else { + Some(joined) + } + } + _ => None, + } +} + +fn extract_url_citations(message: &Value) -> Vec { + let mut seen_urls = HashSet::new(); + let mut citations = Vec::new(); + + let Some(annotations) = message + .get("annotations") + .and_then(|annotations| annotations.as_array()) + else { + return citations; + }; + + for annotation in annotations { + let Some(citation) = annotation.get("url_citation") else { + continue; + }; + + let Some(url) = citation.get("url").and_then(|url| url.as_str()) else { + continue; + }; + + let url = url.trim(); + if url.is_empty() || !seen_urls.insert(url.to_string()) { + continue; + } + + let title = citation + .get("title") + .and_then(|title| title.as_str()) + .map(str::trim) + .filter(|title| !title.is_empty()) + .unwrap_or(url); + + citations.push(UrlCitation { + title: title.to_string(), + url: url.to_string(), + }); + } + + citations +} + +fn extract_blocked_searches(message: &Value) -> Vec { + message + .get("blocked_searches") + .and_then(|blocked_searches| blocked_searches.as_array()) + .map(|blocked_searches| { + blocked_searches + .iter() + .filter_map(|blocked| { + let query = blocked.get("query")?.as_str()?.trim(); + let reason = blocked.get("reason")?.as_str()?.trim(); + + if query.is_empty() || reason.is_empty() { + return None; + } + + Some(BlockedSearch { + query: query.to_string(), + reason: reason.to_string(), + }) + }) + .collect() + }) + .unwrap_or_default() +} + /// Execute a tool by name with the given arguments /// /// This is the main entry point for tool execution. It routes to the appropriate @@ -239,6 +619,7 @@ async fn execute_kagi_search(query: &str, client: &Arc) -> Result) -> Result>, brave_client: Option<&Arc>, kagi_client: Option<&Arc>, ) -> Result { @@ -266,7 +648,11 @@ pub async fn execute_tool( .and_then(|q| q.as_str()) .ok_or_else(|| "Missing 'query' argument for web_search".to_string())?; - execute_web_search(query, brave_client, kagi_client).await + if let Some(context) = web_search_context { + execute_web_search_with_context(context, query, brave_client, kagi_client).await + } else { + execute_web_search(query, brave_client, kagi_client).await + } } _ => { error!("Unknown tool requested: {}", tool_name); @@ -341,7 +727,7 @@ mod tests { async fn test_execute_tool_missing_args() { // Test with None client - should fail on missing args before client check let args = json!({}); - let result = execute_tool("web_search", &args, None, None).await; + let result = execute_tool("web_search", &args, None, None, None).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Missing 'query'")); } @@ -349,11 +735,99 @@ mod tests { #[tokio::test] async fn test_execute_tool_unknown() { let args = json!({"query": "test"}); - let result = execute_tool("unknown_tool", &args, None, None).await; + let result = execute_tool("unknown_tool", &args, None, None, None).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Unknown tool")); } + #[test] + fn test_format_tinfoil_web_search_response_includes_sources() { + let response = json!({ + "choices": [{ + "message": { + "content": "SpaceX generated about $15.6 billion in revenue in 2025.【1】", + "annotations": [{ + "type": "url_citation", + "url_citation": { + "title": "SpaceX Revenue Report", + "url": "https://example.com/spacex-revenue" + } + }] + } + }] + }); + + let result = format_tinfoil_web_search_response(&response).unwrap(); + assert!(result.contains("Search Summary:")); + assert!(result.contains("SpaceX generated about $15.6 billion")); + assert!(result.contains("Sources:")); + assert!(result.contains("SpaceX Revenue Report")); + assert!(result.contains("https://example.com/spacex-revenue")); + } + + #[test] + fn test_format_tinfoil_web_search_response_includes_blocked_searches() { + let response = json!({ + "choices": [{ + "message": { + "content": "I could not use web search for this request.", + "blocked_searches": [{ + "query": "search for account number 1234567890", + "reason": "Bank account number detected" + }] + } + }] + }); + + let result = format_tinfoil_web_search_response(&response).unwrap(); + assert!(result.contains("Blocked Searches:")); + assert!(result.contains("Bank account number detected")); + } + + #[test] + fn test_extract_completion_text_from_content_array() { + let message = json!({ + "content": [ + {"type": "text", "text": "First line"}, + {"type": "text", "text": "Second line"} + ] + }); + + let text = extract_completion_text(Some(&message)).unwrap(); + assert_eq!(text, "First line\nSecond line"); + } + + #[test] + fn test_sanitize_tinfoil_summary_strips_embedded_sources_section() { + let summary = sanitize_tinfoil_summary( + "It will be cloudy today.\n\nSources:\nExample Weather: https://example.com/weather", + ); + + assert_eq!(summary, "It will be cloudy today."); + } + + #[test] + fn test_format_tinfoil_web_search_response_avoids_duplicate_sources_sections() { + let response = json!({ + "choices": [{ + "message": { + "content": "It will be cloudy today.【1】\n\nSources:\nExample Weather: https://example.com/weather", + "annotations": [{ + "type": "url_citation", + "url_citation": { + "title": "Example Weather", + "url": "https://example.com/weather" + } + }] + } + }] + }); + + let result = format_tinfoil_web_search_response(&response).unwrap(); + assert_eq!(result.matches("Sources:").count(), 1); + assert!(result.contains("It will be cloudy today.")); + } + #[test] fn test_tool_registry() { let registry = ToolRegistry::new(); diff --git a/tinfoil-proxy/dist/tinfoil-proxy b/tinfoil-proxy/dist/tinfoil-proxy index 5661f9a9116f70421060b0ae707d857292319d8a..9137b0aacec3edb0d1f43c010c5028883e67c565 100755 GIT binary patch literal 24641720 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