use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use smooth_operator_core::tool::ToolSchema;
use smooth_operator_core::{KnowledgeBase, KnowledgeResult, Tool};
use crate::access_control::{AccessContext, AclKnowledgeStore};
use crate::curation::{CuratedKnowledgeStore, RetrievalFilter};
use crate::rerank::{apply_optional_rerank, Reranker};
pub type KnowledgeResultSink = Arc<Mutex<Vec<KnowledgeResult>>>;
const DEFAULT_LIMIT: usize = 3;
const RERANK_OVERFETCH: usize = 4;
pub struct KnowledgeSearchTool {
knowledge: Arc<dyn KnowledgeBase>,
reranker: Option<Arc<dyn Reranker>>,
result_sink: Option<KnowledgeResultSink>,
}
impl KnowledgeSearchTool {
#[must_use]
pub fn new(knowledge: Arc<dyn KnowledgeBase>) -> Self {
Self {
knowledge,
reranker: None,
result_sink: None,
}
}
#[must_use]
pub fn with_access_control(store: &AclKnowledgeStore, context: AccessContext) -> Self {
Self {
knowledge: store.reader(context),
reranker: None,
result_sink: None,
}
}
#[must_use]
pub fn with_curation(
store: &CuratedKnowledgeStore,
context: AccessContext,
filter: RetrievalFilter,
) -> Self {
Self {
knowledge: store.reader(filter, context),
reranker: None,
result_sink: None,
}
}
#[must_use]
pub fn with_result_sink(mut self, sink: KnowledgeResultSink) -> Self {
self.result_sink = Some(sink);
self
}
#[must_use]
pub fn with_reranker(mut self, reranker: Arc<dyn Reranker>) -> Self {
self.reranker = Some(reranker);
self
}
}
#[async_trait]
impl Tool for KnowledgeSearchTool {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "knowledge_search".to_string(),
description: "Search the organization's knowledge base for facts relevant to the user's \
question (policies, product details, documentation). Returns the most \
relevant snippets with their source and relevance score. Call this before \
answering any question that depends on organization-specific knowledge."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query — phrase it with the key terms you expect to \
appear in the answer (e.g. 'return policy refund window')."
},
"limit": {
"type": "integer",
"description": "Maximum number of snippets to return (default 3).",
"minimum": 1,
"maximum": 10
}
},
"required": ["query"]
}),
}
}
async fn execute(&self, arguments: serde_json::Value) -> anyhow::Result<String> {
let query = arguments
.get("query")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
anyhow::anyhow!("knowledge_search requires a string 'query' argument")
})?;
let limit = arguments
.get("limit")
.and_then(serde_json::Value::as_u64)
.map_or(DEFAULT_LIMIT, |n| (n as usize).clamp(1, 10));
let fetch = if self.reranker.is_some() {
limit.saturating_mul(RERANK_OVERFETCH)
} else {
limit
};
let candidates = self.knowledge.query(query, fetch)?;
let results = apply_optional_rerank(self.reranker.as_ref(), query, candidates, limit).await;
if let Some(sink) = &self.result_sink {
if let Ok(mut guard) = sink.lock() {
guard.extend(results.iter().cloned());
}
}
if results.is_empty() {
return Ok(format!(
"No knowledge base results found for query: {query:?}"
));
}
let mut out = format!(
"Found {} knowledge base result(s) for {query:?}:\n",
results.len()
);
for (i, result) in results.iter().enumerate() {
out.push_str(&format!(
"{}. [source={} | id={} | relevance={:.2}]\n{}\n",
i + 1,
result.source,
result.document_id,
result.score,
result.chunk,
));
}
Ok(out)
}
fn is_read_only(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use smooth_operator_core::{Document, DocumentType, InMemoryKnowledge};
fn seeded_kb() -> Arc<dyn KnowledgeBase> {
let kb = InMemoryKnowledge::new();
kb.ingest(Document::new(
"SmooAI returns are accepted within 30 days of delivery for a full refund.",
"policies/returns.md",
DocumentType::Documentation,
))
.expect("ingest returns policy");
kb.ingest(Document::new(
"Standard shipping takes 5 to 7 business days.",
"policies/shipping.md",
DocumentType::Documentation,
))
.expect("ingest shipping policy");
Arc::new(kb)
}
#[tokio::test]
async fn schema_exposes_query_parameter() {
let tool = KnowledgeSearchTool::new(Arc::new(InMemoryKnowledge::new()));
let schema = tool.schema();
assert_eq!(schema.name, "knowledge_search");
assert_eq!(schema.parameters["properties"]["query"]["type"], "string");
assert_eq!(schema.parameters["required"][0], "query");
assert!(tool.is_read_only());
}
#[tokio::test]
async fn execute_returns_matching_document() {
let tool = KnowledgeSearchTool::new(seeded_kb());
let out = tool
.execute(serde_json::json!({ "query": "return policy refund" }))
.await
.expect("execute");
assert!(out.contains("30 days"), "expected returns fact, got: {out}");
assert!(
out.contains("policies/returns.md"),
"expected source, got: {out}"
);
}
#[tokio::test]
async fn execute_no_match_reports_empty() {
let tool = KnowledgeSearchTool::new(seeded_kb());
let out = tool
.execute(serde_json::json!({ "query": "warranty electronics voltage" }))
.await
.expect("execute");
assert!(out.contains("No knowledge base results"), "got: {out}");
}
#[tokio::test]
async fn execute_rejects_missing_query() {
let tool = KnowledgeSearchTool::new(seeded_kb());
let err = tool
.execute(serde_json::json!({ "limit": 3 }))
.await
.expect_err("missing query should error");
assert!(err.to_string().contains("query"));
}
#[tokio::test]
async fn execute_without_reranker_is_unchanged() {
let tool = KnowledgeSearchTool::new(seeded_kb());
assert!(tool.reranker.is_none());
let out = tool
.execute(serde_json::json!({ "query": "return policy refund" }))
.await
.expect("execute");
assert!(out.contains("30 days"), "got: {out}");
}
#[tokio::test]
async fn execute_with_reranker_runs_and_returns_results() {
use crate::rerank::LexicalReranker;
let tool =
KnowledgeSearchTool::new(seeded_kb()).with_reranker(Arc::new(LexicalReranker::new()));
assert!(tool.reranker.is_some());
let out = tool
.execute(serde_json::json!({ "query": "return policy refund", "limit": 1 }))
.await
.expect("execute");
assert!(
out.contains("30 days") && out.contains("policies/returns.md"),
"reranked result should still surface the returns fact, got: {out}"
);
}
}