smooth_operator/tools/knowledge_search.rs
1//! The `knowledge_search` tool — the agent's hands on the RAG knowledge base.
2//!
3//! This is the tool half of the knowledge-grounded turn. While
4//! [`AgentConfig::with_knowledge`](smooth_operator_core::AgentConfig::with_knowledge)
5//! lets the engine *auto-inject* a few top results as context before the first
6//! LLM call, a real agent also wants to **decide** to search — to issue its own
7//! query, with its own phrasing, mid-turn. That's what this tool exposes: a
8//! `knowledge_search({ "query": "…" })` call the model can emit, which queries
9//! the [`StorageAdapter`](crate::adapter::StorageAdapter)'s
10//! [`KnowledgeBase`](smooth_operator_core::KnowledgeBase) and returns the top-K
11//! matches as text the model reads on the next turn.
12//!
13//! Construct it from the same `Arc<dyn KnowledgeBase>` the runtime hands
14//! `AgentConfig::with_knowledge`, so both paths read the same store.
15
16use std::sync::{Arc, Mutex};
17
18use async_trait::async_trait;
19use smooth_operator_core::tool::ToolSchema;
20use smooth_operator_core::{KnowledgeBase, KnowledgeResult, Tool};
21
22use crate::access_control::{AccessContext, AclKnowledgeStore};
23use crate::curation::{CuratedKnowledgeStore, RetrievalFilter};
24use crate::rerank::{apply_optional_rerank, Reranker};
25
26/// A shared sink the [`KnowledgeSearchTool`] records its structured results into,
27/// so the runtime can collect the sources a turn's `knowledge_search` calls
28/// actually surfaced (for citations) without re-parsing the tool's text output.
29pub type KnowledgeResultSink = Arc<Mutex<Vec<KnowledgeResult>>>;
30
31/// Default number of results returned when the caller doesn't specify `limit`.
32const DEFAULT_LIMIT: usize = 3;
33
34/// Overfetch multiplier when a reranker is configured.
35///
36/// A reranker can only promote what it's given, so we pull a wider candidate set
37/// from the (cheaper, rank-based) knowledge query and let the reranker pick the
38/// final top-K. With no reranker this multiplier is unused — we fetch exactly
39/// `limit`, so default behavior is byte-for-byte unchanged.
40const RERANK_OVERFETCH: usize = 4;
41
42/// A [`Tool`] that searches the agent's knowledge base.
43///
44/// Holds an `Arc<dyn KnowledgeBase>` — the exact handle returned by
45/// [`StorageAdapter::knowledge`](crate::adapter::StorageAdapter::knowledge) —
46/// so a tool call hits the same store the engine auto-injects from.
47///
48/// Optionally holds an `Arc<dyn Reranker>`: when set, the tool overfetches
49/// candidates from the knowledge query and reorders them with the reranker
50/// before returning the top-K (feature gap G8). When unset (the default), behavior
51/// is unchanged — the knowledge query's own top-`limit` is returned as-is.
52pub struct KnowledgeSearchTool {
53 knowledge: Arc<dyn KnowledgeBase>,
54 reranker: Option<Arc<dyn Reranker>>,
55 /// Optional sink the tool records the structured results of every search
56 /// into. When set (via [`with_result_sink`](Self::with_result_sink)), the
57 /// runtime reads it after the turn to build citations from the documents the
58 /// agent's `knowledge_search` calls surfaced. `None` ⇒ no recording
59 /// (default), so existing behavior is byte-for-byte unchanged.
60 result_sink: Option<KnowledgeResultSink>,
61}
62
63impl KnowledgeSearchTool {
64 /// Build the tool over a knowledge base handle.
65 ///
66 /// The handle may itself be an ACL-filtering reader (e.g. from
67 /// [`AclKnowledgeStore::reader`](crate::access_control::AclKnowledgeStore::reader)),
68 /// in which case the tool's searches are document-level access-controlled.
69 /// Use [`with_access_control`](Self::with_access_control) to build that
70 /// reader from a store + requester in one step.
71 ///
72 /// No reranker is configured by default; add one with
73 /// [`with_reranker`](Self::with_reranker).
74 #[must_use]
75 pub fn new(knowledge: Arc<dyn KnowledgeBase>) -> Self {
76 Self {
77 knowledge,
78 reranker: None,
79 result_sink: None,
80 }
81 }
82
83 /// Build the tool bound to a requester's [`AccessContext`] over an
84 /// [`AclKnowledgeStore`] (feature gap G3): every search reads through an
85 /// ACL-filtering reader, so results the requester is not entitled to are
86 /// dropped before they reach the model.
87 #[must_use]
88 pub fn with_access_control(store: &AclKnowledgeStore, context: AccessContext) -> Self {
89 Self {
90 knowledge: store.reader(context),
91 reranker: None,
92 result_sink: None,
93 }
94 }
95
96 /// Build the tool bound to a query-time [`RetrievalFilter`] +
97 /// [`AccessContext`] over a [`CuratedKnowledgeStore`] (Phase 11): every search
98 /// reads through a curation reader that scopes results to the requested
99 /// document sets / metadata, re-ranks by per-document boost, and still drops
100 /// documents the requester is not entitled to (ACL ∧ curation).
101 #[must_use]
102 pub fn with_curation(
103 store: &CuratedKnowledgeStore,
104 context: AccessContext,
105 filter: RetrievalFilter,
106 ) -> Self {
107 Self {
108 knowledge: store.reader(filter, context),
109 reranker: None,
110 result_sink: None,
111 }
112 }
113
114 /// Record the structured results of every search into `sink` (feature gap:
115 /// structured citations). The runtime drains the sink after a turn to build
116 /// the `eventual_response`'s `citations` from the documents the agent's
117 /// `knowledge_search` calls actually surfaced. Leaving it unset keeps the
118 /// tool's behavior unchanged.
119 #[must_use]
120 pub fn with_result_sink(mut self, sink: KnowledgeResultSink) -> Self {
121 self.result_sink = Some(sink);
122 self
123 }
124
125 /// Attach an optional reranker stage (feature gap G8).
126 ///
127 /// When set, the tool overfetches candidates and reorders the top-K with the
128 /// [`Reranker`] before returning. Pass a [`LexicalReranker`](crate::rerank::LexicalReranker)
129 /// for a deterministic offline reorder, or an adapter-side `GatewayReranker`
130 /// for a paid cross-encoder. Leaving it unset keeps default behavior.
131 #[must_use]
132 pub fn with_reranker(mut self, reranker: Arc<dyn Reranker>) -> Self {
133 self.reranker = Some(reranker);
134 self
135 }
136}
137
138#[async_trait]
139impl Tool for KnowledgeSearchTool {
140 fn schema(&self) -> ToolSchema {
141 ToolSchema {
142 name: "knowledge_search".to_string(),
143 description: "Search the organization's knowledge base for facts relevant to the user's \
144 question (policies, product details, documentation). Returns the most \
145 relevant snippets with their source and relevance score. Call this before \
146 answering any question that depends on organization-specific knowledge."
147 .to_string(),
148 parameters: serde_json::json!({
149 "type": "object",
150 "properties": {
151 "query": {
152 "type": "string",
153 "description": "The search query — phrase it with the key terms you expect to \
154 appear in the answer (e.g. 'return policy refund window')."
155 },
156 "limit": {
157 "type": "integer",
158 "description": "Maximum number of snippets to return (default 3).",
159 "minimum": 1,
160 "maximum": 10
161 }
162 },
163 "required": ["query"]
164 }),
165 }
166 }
167
168 async fn execute(&self, arguments: serde_json::Value) -> anyhow::Result<String> {
169 let query = arguments
170 .get("query")
171 .and_then(serde_json::Value::as_str)
172 .ok_or_else(|| {
173 anyhow::anyhow!("knowledge_search requires a string 'query' argument")
174 })?;
175
176 let limit = arguments
177 .get("limit")
178 .and_then(serde_json::Value::as_u64)
179 .map_or(DEFAULT_LIMIT, |n| (n as usize).clamp(1, 10));
180
181 // When a reranker is configured, overfetch a wider candidate set so the
182 // reranker has room to promote a lexically/semantically better doc that
183 // the rank-based query placed lower. With no reranker, fetch exactly
184 // `limit` so behavior is unchanged.
185 let fetch = if self.reranker.is_some() {
186 limit.saturating_mul(RERANK_OVERFETCH)
187 } else {
188 limit
189 };
190
191 // `KnowledgeBase::query` is synchronous in smooth-operator; the in-memory
192 // backend is a CPU-bound keyword scan, so calling it directly here is
193 // fine (no blocking I/O to offload to a worker thread).
194 let candidates = self.knowledge.query(query, fetch)?;
195
196 // Opt-in rerank stage (feature gap G8): reorder + truncate to `limit`. With
197 // `None` this is just a truncation, preserving the query's own order.
198 let results = apply_optional_rerank(self.reranker.as_ref(), query, candidates, limit).await;
199
200 // Record the structured results so the runtime can build citations from
201 // the sources this search surfaced. Done before the empty-check so an
202 // empty search records nothing (no spurious citation).
203 if let Some(sink) = &self.result_sink {
204 if let Ok(mut guard) = sink.lock() {
205 guard.extend(results.iter().cloned());
206 }
207 }
208
209 if results.is_empty() {
210 return Ok(format!(
211 "No knowledge base results found for query: {query:?}"
212 ));
213 }
214
215 let mut out = format!(
216 "Found {} knowledge base result(s) for {query:?}:\n",
217 results.len()
218 );
219 for (i, result) in results.iter().enumerate() {
220 out.push_str(&format!(
221 "{}. [source={} | id={} | relevance={:.2}]\n{}\n",
222 i + 1,
223 result.source,
224 result.document_id,
225 result.score,
226 result.chunk,
227 ));
228 }
229 Ok(out)
230 }
231
232 fn is_read_only(&self) -> bool {
233 true
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use smooth_operator_core::{Document, DocumentType, InMemoryKnowledge};
241
242 fn seeded_kb() -> Arc<dyn KnowledgeBase> {
243 let kb = InMemoryKnowledge::new();
244 kb.ingest(Document::new(
245 "SmooAI returns are accepted within 30 days of delivery for a full refund.",
246 "policies/returns.md",
247 DocumentType::Documentation,
248 ))
249 .expect("ingest returns policy");
250 kb.ingest(Document::new(
251 "Standard shipping takes 5 to 7 business days.",
252 "policies/shipping.md",
253 DocumentType::Documentation,
254 ))
255 .expect("ingest shipping policy");
256 Arc::new(kb)
257 }
258
259 #[tokio::test]
260 async fn schema_exposes_query_parameter() {
261 let tool = KnowledgeSearchTool::new(Arc::new(InMemoryKnowledge::new()));
262 let schema = tool.schema();
263 assert_eq!(schema.name, "knowledge_search");
264 assert_eq!(schema.parameters["properties"]["query"]["type"], "string");
265 assert_eq!(schema.parameters["required"][0], "query");
266 assert!(tool.is_read_only());
267 }
268
269 #[tokio::test]
270 async fn execute_returns_matching_document() {
271 let tool = KnowledgeSearchTool::new(seeded_kb());
272 let out = tool
273 .execute(serde_json::json!({ "query": "return policy refund" }))
274 .await
275 .expect("execute");
276 assert!(out.contains("30 days"), "expected returns fact, got: {out}");
277 assert!(
278 out.contains("policies/returns.md"),
279 "expected source, got: {out}"
280 );
281 }
282
283 #[tokio::test]
284 async fn execute_no_match_reports_empty() {
285 let tool = KnowledgeSearchTool::new(seeded_kb());
286 let out = tool
287 .execute(serde_json::json!({ "query": "warranty electronics voltage" }))
288 .await
289 .expect("execute");
290 assert!(out.contains("No knowledge base results"), "got: {out}");
291 }
292
293 #[tokio::test]
294 async fn execute_rejects_missing_query() {
295 let tool = KnowledgeSearchTool::new(seeded_kb());
296 let err = tool
297 .execute(serde_json::json!({ "limit": 3 }))
298 .await
299 .expect_err("missing query should error");
300 assert!(err.to_string().contains("query"));
301 }
302
303 /// The reranker is opt-in: a tool with no reranker returns the knowledge
304 /// query's own results unchanged.
305 #[tokio::test]
306 async fn execute_without_reranker_is_unchanged() {
307 let tool = KnowledgeSearchTool::new(seeded_kb());
308 assert!(tool.reranker.is_none());
309 let out = tool
310 .execute(serde_json::json!({ "query": "return policy refund" }))
311 .await
312 .expect("execute");
313 assert!(out.contains("30 days"), "got: {out}");
314 }
315
316 /// Wiring smoke test: a tool built `with_reranker` runs the rerank stage and
317 /// still returns the relevant result.
318 #[tokio::test]
319 async fn execute_with_reranker_runs_and_returns_results() {
320 use crate::rerank::LexicalReranker;
321
322 let tool =
323 KnowledgeSearchTool::new(seeded_kb()).with_reranker(Arc::new(LexicalReranker::new()));
324 assert!(tool.reranker.is_some());
325 let out = tool
326 .execute(serde_json::json!({ "query": "return policy refund", "limit": 1 }))
327 .await
328 .expect("execute");
329 assert!(
330 out.contains("30 days") && out.contains("policies/returns.md"),
331 "reranked result should still surface the returns fact, got: {out}"
332 );
333 }
334}