1use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
2use crate::tools::typed::TypedTool;
3use async_trait::async_trait;
4use parking_lot::Mutex;
5use schemars::JsonSchema;
6use serde::Deserialize;
7use serde_json::{Value, json};
8use std::collections::HashMap;
9use std::sync::Arc;
10use tokio::sync::oneshot;
11
12pub use oxibrowser::SearchResult;
14
15#[derive(Debug)]
19pub struct SearchCache {
20 entries: Mutex<HashMap<String, CachedSearch>>,
22 max_entries: usize,
24}
25
26#[derive(Debug, Clone)]
27struct CachedSearch {
28 query: String,
29 results: Vec<SearchResult>,
30}
31
32impl Default for SearchCache {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38impl SearchCache {
39 pub fn new() -> Self {
41 Self::with_capacity(64)
42 }
43
44 pub fn with_capacity(max_entries: usize) -> Self {
46 Self {
47 entries: Mutex::new(HashMap::new()),
48 max_entries,
49 }
50 }
51
52 pub fn insert(&self, query: &str, results: Vec<SearchResult>) -> String {
54 let id = generate_search_id();
55 let cached = CachedSearch {
56 query: query.to_string(),
57 results,
58 };
59
60 let mut entries = self.entries.lock();
61
62 while entries.len() >= self.max_entries {
64 if let Some(key) = entries.keys().next().cloned() {
66 entries.remove(&key);
67 }
68 }
69
70 entries.insert(id.clone(), cached);
71 id
72 }
73
74 pub fn get(&self, search_id: &str) -> Option<(String, Vec<SearchResult>)> {
76 let entries = self.entries.lock();
77 entries
78 .get(search_id)
79 .map(|c| (c.query.clone(), c.results.clone()))
80 }
81}
82
83fn generate_search_id() -> String {
85 let ts = std::time::SystemTime::now()
86 .duration_since(std::time::UNIX_EPOCH)
87 .unwrap_or_default()
88 .as_millis();
89 let rand_part: u32 = rng::random();
90 format!("{:x}{:06x}", ts, rand_part & 0xFFFFFF)
91}
92
93pub struct GetSearchResultsTool {
97 cache: Arc<SearchCache>,
98}
99
100#[derive(Deserialize, JsonSchema)]
101#[allow(missing_docs)]
102pub struct GetSearchResultsArgs {
103 #[serde(rename = "searchId")]
104 search_id: String,
105}
106
107impl GetSearchResultsTool {
108 pub fn new(cache: Arc<SearchCache>) -> Self {
110 Self { cache }
111 }
112}
113
114#[async_trait]
115impl AgentTool for GetSearchResultsTool {
116 fn name(&self) -> &str {
117 "get_search_results"
118 }
119
120 fn label(&self) -> &str {
121 "Get Search Results"
122 }
123
124 fn description(&self) -> &str {
125 "Retrieve previous search results by ID. Use this to look up results from a prior web_search call."
126 }
127
128 fn parameters_schema(&self) -> Value {
129 json!({
130 "type": "object",
131 "properties": {
132 "searchId": {
133 "type": "string",
134 "description": "The search ID returned by a previous web_search call"
135 }
136 },
137 "required": ["searchId"]
138 })
139 }
140
141 async fn execute(
142 &self,
143 _tool_call_id: &str,
144 params: Value,
145 _signal: Option<oneshot::Receiver<()>>,
146 _ctx: &ToolContext,
147 ) -> Result<AgentToolResult, ToolError> {
148 let args: GetSearchResultsArgs =
149 serde_json::from_value(params).map_err(|e| format!("invalid params: {e}"))?;
150 self.execute_typed(_tool_call_id, args, _signal, _ctx).await
151 }
152}
153
154#[async_trait]
155impl TypedTool for GetSearchResultsTool {
156 type Args = GetSearchResultsArgs;
157
158 async fn execute_typed(
159 &self,
160 _tool_call_id: &str,
161 args: Self::Args,
162 _signal: Option<oneshot::Receiver<()>>,
163 _ctx: &ToolContext,
164 ) -> Result<AgentToolResult, ToolError> {
165 let (query, results) = self
166 .cache
167 .get(&args.search_id)
168 .ok_or_else(|| format!("Search not found for ID: {}", args.search_id))?;
169 let mut output = format!("Cached results for: \"{}\"\n\n", query);
170 for (i, result) in results.iter().enumerate() {
171 output.push_str(&format!(
172 "{}. **{}**\n {}\n {}\n\n",
173 i + 1,
174 result.title,
175 result.url,
176 result.snippet
177 ));
178 }
179 let results_json: Vec<Value> = results.iter().map(|r| {
180 json!({"title": r.title, "url": r.url, "snippet": r.snippet, "source": r.source})
181 }).collect();
182 Ok(AgentToolResult::success(output).with_metadata(
183 json!({ "results": results_json, "query": query, "searchId": args.search_id }),
184 ))
185 }
186}
187#[allow(dead_code)]
188mod rng {
189 #[allow(unused_imports)]
190 use std::cell::Cell;
191 #[allow(unused_imports)]
192 use std::time::SystemTime;
193
194 thread_local! {
195 static SEED: Cell<u64> = const { Cell::new(0) };
196 }
197
198 pub fn random() -> u32 {
199 SEED.with(|s| {
200 let mut x = if s.get() == 0 {
201 let ns = SystemTime::now()
202 .duration_since(SystemTime::UNIX_EPOCH)
203 .unwrap_or_default()
204 .as_nanos() as u64;
205 ns ^ (thread_id() as u64)
206 } else {
207 s.get()
208 };
209 x ^= x << 13;
210 x ^= x >> 7;
211 x ^= x << 17;
212 s.set(x);
213 (x & 0xFFFFFFFF) as u32
214 })
215 }
216
217 fn thread_id() -> usize {
218 thread_local! { static ANCHOR: () = const { }; }
219 ANCHOR.with(|_| &ANCHOR as *const _ as usize)
220 }
221}
222
223#[cfg(test)]
226mod tests {
227 use super::*;
228
229 fn make_result(title: &str, url: &str, snippet: &str) -> SearchResult {
230 SearchResult {
231 title: title.to_string(),
232 url: url.to_string(),
233 snippet: snippet.to_string(),
234 source: "test".to_string(),
235 extra: None,
236 }
237 }
238
239 #[test]
240 fn test_cache_insert_and_get() {
241 let cache = SearchCache::new();
242 let results = vec![make_result("Test", "https://example.com", "Test snippet")];
243
244 let id = cache.insert("test query", results.clone());
245 let (query, retrieved) = cache.get(&id).unwrap();
246 assert_eq!(query, "test query");
247 assert_eq!(retrieved.len(), 1);
248 assert_eq!(retrieved[0].title, "Test");
249 }
250
251 #[test]
252 fn test_cache_miss() {
253 let cache = SearchCache::new();
254 assert!(cache.get("nonexistent").is_none());
255 }
256
257 #[test]
258 fn test_cache_eviction() {
259 let cache = SearchCache::with_capacity(3);
260
261 let id1 = cache.insert("q1", vec![]);
262 let id2 = cache.insert("q2", vec![]);
263 let id3 = cache.insert("q3", vec![]);
264 let _id4 = cache.insert("q4", vec![]);
265
266 let found = [&id1, &id2, &id3]
268 .iter()
269 .filter(|id| cache.get(id).is_some())
270 .count();
271 assert!(found < 3);
272 assert!(cache.get(&_id4).is_some());
273 }
274
275 #[test]
276 fn test_generate_search_id_unique() {
277 let id1 = generate_search_id();
278 let id2 = generate_search_id();
279 assert_ne!(id1, id2);
280 }
281
282 #[tokio::test]
283 async fn test_get_search_results_tool() {
284 let cache = Arc::new(SearchCache::new());
285 let results = vec![make_result("Rust", "https://rust-lang.org", "A language")];
286 let id = cache.insert("rust lang", results);
287
288 let tool = GetSearchResultsTool::new(cache);
289 let result = tool
290 .execute(
291 "test",
292 json!({ "searchId": id }),
293 None,
294 &ToolContext::default(),
295 )
296 .await
297 .unwrap();
298
299 assert!(result.success);
300 assert!(result.output.contains("Rust"));
301 assert!(result.output.contains("rust-lang.org"));
302 }
303
304 #[tokio::test]
305 async fn test_get_search_results_not_found() {
306 let cache = Arc::new(SearchCache::new());
307 let tool = GetSearchResultsTool::new(cache);
308 let result = tool
309 .execute(
310 "test",
311 json!({ "searchId": "bad-id" }),
312 None,
313 &ToolContext::default(),
314 )
315 .await;
316
317 assert!(result.is_err());
318 }
319
320 #[test]
321 fn test_get_search_results_schema() {
322 let cache = Arc::new(SearchCache::new());
323 let tool = GetSearchResultsTool::new(cache);
324 let schema = tool.parameters_schema();
325 assert_eq!(schema["type"], "object");
326 assert!(schema["properties"]["searchId"].is_object());
327 }
328}