Skip to main content

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}