1use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15
16use super::{Capability, CapabilityLocalization, CapabilityStatus, RiskLevel};
17use crate::tools::{Tool, ToolExecutionResult};
18use crate::traits::ToolContext;
19
20pub const KNOWLEDGE_BASE_CAPABILITY_ID: &str = "knowledge_base";
22
23const ENTRY_KINDS: &[&str] = &["note", "table", "business", "query", "runbook"];
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct KnowledgeBaseConfig {
30 #[serde(default)]
32 pub bases: Vec<String>,
33 #[serde(default)]
36 pub kinds: Vec<String>,
37}
38
39pub fn validate_knowledge_base_config(cfg: &KnowledgeBaseConfig) -> Result<(), String> {
40 for base in &cfg.bases {
41 if !is_valid_kb_id(base) {
42 return Err(format!(
43 "knowledge_base bases[*] must be a kb_<32-hex> id, got '{base}'"
44 ));
45 }
46 }
47 let mut seen = std::collections::HashSet::new();
48 for base in &cfg.bases {
49 if !seen.insert(base) {
50 return Err(format!(
51 "knowledge_base bases[*] contains duplicate '{base}'"
52 ));
53 }
54 }
55 for kind in &cfg.kinds {
56 if !ENTRY_KINDS.contains(&kind.as_str()) {
57 return Err(format!(
58 "knowledge_base kinds[*] must be one of {:?}, got '{kind}'",
59 ENTRY_KINDS
60 ));
61 }
62 }
63 Ok(())
64}
65
66fn is_valid_kb_id(s: &str) -> bool {
67 s.len() == 35
68 && s.starts_with("kb_")
69 && s[3..]
70 .chars()
71 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
72}
73
74pub struct SearchKnowledgeTool {
78 config: KnowledgeBaseConfig,
79}
80
81impl SearchKnowledgeTool {
82 pub fn new(config: KnowledgeBaseConfig) -> Self {
83 Self { config }
84 }
85}
86
87#[async_trait]
88impl Tool for SearchKnowledgeTool {
89 fn name(&self) -> &str {
90 "search_knowledge"
91 }
92
93 fn description(&self) -> &str {
94 "Search curated organization Knowledge Bases (table docs, business rules, \
95 validated query templates, runbooks) by keyword. Consult this before \
96 answering data questions and cite results by their kbe_ id."
97 }
98
99 fn parameters_schema(&self) -> Value {
100 json!({
101 "type": "object",
102 "properties": {
103 "query": { "type": "string", "description": "Keyword search across entry title and body" },
104 "kind": {
105 "type": "string",
106 "enum": ["note", "table", "business", "query", "runbook"],
107 "description": "Optional filter by entry kind"
108 },
109 "tags": { "type": "array", "items": { "type": "string" }, "description": "Optional tag filter" },
110 "limit": { "type": "integer", "minimum": 1, "maximum": 25, "default": 10 }
111 },
112 "required": ["query"],
113 "additionalProperties": false
114 })
115 }
116
117 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
118 ToolExecutionResult::tool_error("search_knowledge requires execution context")
119 }
120
121 async fn execute_with_context(
122 &self,
123 arguments: Value,
124 context: &ToolContext,
125 ) -> ToolExecutionResult {
126 let query = match arguments.get("query").and_then(|v| v.as_str()) {
127 Some(q) if !q.trim().is_empty() => q.trim().to_string(),
128 _ => return ToolExecutionResult::tool_error("Missing required parameter: query"),
129 };
130
131 if self.config.bases.is_empty() {
133 return ToolExecutionResult::success(json!({ "count": 0, "results": [] }));
134 }
135
136 let Some(store) = context.knowledge_store.as_ref() else {
137 return ToolExecutionResult::tool_error(
138 "Knowledge search is not available in this execution context",
139 );
140 };
141 let Some(org_id) = context.org_id else {
142 return ToolExecutionResult::tool_error(
143 "Knowledge search requires an organization context",
144 );
145 };
146
147 let kind = arguments
152 .get("kind")
153 .and_then(|v| v.as_str())
154 .map(|s| s.to_string())
155 .or_else(|| match self.config.kinds.as_slice() {
156 [single] => Some(single.clone()),
157 _ => None,
158 });
159 let tags: Vec<String> = arguments
160 .get("tags")
161 .and_then(|v| v.as_array())
162 .map(|a| {
163 a.iter()
164 .filter_map(|v| v.as_str().map(|s| s.to_lowercase()))
165 .collect()
166 })
167 .unwrap_or_default();
168 let limit = arguments
169 .get("limit")
170 .and_then(|v| v.as_u64())
171 .map(|v| (v as usize).clamp(1, 25))
172 .unwrap_or(10);
173
174 match store
175 .search_knowledge(
176 org_id,
177 &self.config.bases,
178 &query,
179 kind.as_deref(),
180 &tags,
181 limit,
182 )
183 .await
184 {
185 Ok(hits) => ToolExecutionResult::success(json!({
186 "count": hits.len(),
187 "results": hits,
188 })),
189 Err(e) => {
190 ToolExecutionResult::internal_error_msg(format!("knowledge search failed: {e}"))
191 }
192 }
193 }
194
195 fn requires_context(&self) -> bool {
196 true
197 }
198
199 fn deferrable_policy(&self) -> crate::tool_types::DeferrablePolicy {
200 crate::tool_types::DeferrablePolicy::Never
204 }
205}
206
207pub struct KnowledgeBaseCapability;
208
209impl Capability for KnowledgeBaseCapability {
210 fn id(&self) -> &str {
211 KNOWLEDGE_BASE_CAPABILITY_ID
212 }
213
214 fn name(&self) -> &str {
215 "Knowledge Base"
216 }
217
218 fn description(&self) -> &str {
219 "Bind an agent to curated org Knowledge Bases and give it the \
220 `search_knowledge` tool to ground answers in human-edited table docs, \
221 business rules, validated SQL templates, and runbooks. \
222 See `specs/knowledge-bases.md`."
223 }
224
225 fn status(&self) -> CapabilityStatus {
226 CapabilityStatus::Available
227 }
228
229 fn icon(&self) -> Option<&str> {
230 Some("library")
231 }
232
233 fn category(&self) -> Option<&str> {
234 Some("Knowledge")
235 }
236
237 fn features(&self) -> Vec<&'static str> {
238 vec!["knowledge"]
239 }
240
241 fn risk_level(&self) -> RiskLevel {
242 RiskLevel::Low
243 }
244
245 fn system_prompt_addition(&self) -> Option<&str> {
246 Some(
247 "You can search curated organization knowledge with the `search_knowledge` tool \
248 (table docs, business rules, validated queries, runbooks). Consult it before \
249 answering data questions, and cite the entries you use by their kbe_ id.",
250 )
251 }
252
253 fn tools(&self) -> Vec<Box<dyn Tool>> {
254 self.tools_with_config(&Value::Null)
255 }
256
257 fn tools_with_config(&self, config: &Value) -> Vec<Box<dyn Tool>> {
258 let cfg: KnowledgeBaseConfig = serde_json::from_value(config.clone()).unwrap_or_default();
259 vec![Box::new(SearchKnowledgeTool::new(cfg))]
260 }
261
262 fn config_schema(&self) -> Option<Value> {
263 Some(json!({
264 "type": "object",
265 "properties": {
266 "bases": {
267 "type": "array",
268 "title": "Knowledge Bases",
269 "description": "Knowledge Base IDs the agent can search.",
270 "items": {
271 "type": "string",
272 "title": "Knowledge Base ID",
273 "description": "Knowledge Base ID (kb_<32-hex>).",
274 "pattern": "^kb_[0-9a-f]{32}$"
275 }
276 },
277 "kinds": {
278 "type": "array",
279 "title": "Default kind filter",
280 "description": "Optional kind filter applied when the agent does not pass `kind`.",
281 "items": {
282 "type": "string",
283 "title": "Entry kind",
284 "description": "Knowledge Base entry kind to include.",
285 "oneOf": [
287 { "const": "note", "title": "Note" },
288 { "const": "table", "title": "Table doc" },
289 { "const": "business", "title": "Business rule" },
290 { "const": "query", "title": "Query template" },
291 { "const": "runbook", "title": "Runbook" }
292 ]
293 }
294 }
295 }
296 }))
297 }
298
299 fn localizations(&self) -> Vec<CapabilityLocalization> {
300 vec![
301 CapabilityLocalization {
302 locale: "en",
303 name: None,
304 description: None,
305 config_description: Some(
306 "Selects which Knowledge Bases the agent can search and an optional \
307 default entry-kind filter.",
308 ),
309 config_overlay: None,
310 },
311 CapabilityLocalization {
312 locale: "uk",
313 name: Some("База знань"),
314 description: Some(
315 "Прив'язує агента до курованих Баз знань організації, щоб відповіді \
316 спиралися на редаговані людьми описи таблиць, бізнес-правила, \
317 перевірені SQL-шаблони та runbook-и.",
318 ),
319 config_description: Some(
320 "Визначає, у яких Базах знань агент може шукати, та необов'язковий \
321 типовий фільтр за видом записів.",
322 ),
323 config_overlay: Some(json!({
324 "properties": {
325 "bases": {
326 "title": "Бази знань",
327 "description": "Ідентифікатори Баз знань, у яких агент може шукати.",
328 "items": {
329 "title": "Ідентифікатор Бази знань",
330 "description": "Ідентифікатор Бази знань (kb_<32-hex>)."
331 }
332 },
333 "kinds": {
334 "title": "Типовий фільтр виду",
335 "description": "Необов'язковий фільтр за видом записів, що застосовується, коли агент не передає kind явно.",
336 "items": {
337 "title": "Вид запису",
338 "description": "Вид записів Бази знань, який потрібно включити.",
339 "enum_labels": {
340 "note": "Нотатка",
341 "table": "Опис таблиці",
342 "business": "Бізнес-правило",
343 "query": "Шаблон запиту",
344 "runbook": "Runbook"
345 }
346 }
347 }
348 }
349 })),
350 },
351 ]
352 }
353
354 fn validate_config(&self, config: &Value) -> Result<(), String> {
355 if config.is_null() {
356 return Ok(());
357 }
358 let typed: KnowledgeBaseConfig = serde_json::from_value(config.clone())
359 .map_err(|e| format!("invalid knowledge_base config: {e}"))?;
360 validate_knowledge_base_config(&typed)
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn id_and_name() {
370 let cap = KnowledgeBaseCapability;
371 assert_eq!(cap.id(), "knowledge_base");
372 assert_eq!(cap.name(), "Knowledge Base");
373 }
374
375 #[test]
376 fn validate_accepts_empty_config() {
377 let cap = KnowledgeBaseCapability;
378 assert!(cap.validate_config(&json!({})).is_ok());
379 assert!(cap.validate_config(&json!({ "bases": [] })).is_ok());
380 assert!(cap.validate_config(&Value::Null).is_ok());
381 }
382
383 #[test]
384 fn validate_accepts_well_formed_bases_and_kinds() {
385 let cap = KnowledgeBaseCapability;
386 let cfg = json!({
387 "bases": ["kb_00000000000000000000000000000001"],
388 "kinds": ["table", "business"]
389 });
390 assert!(cap.validate_config(&cfg).is_ok());
391 }
392
393 #[test]
394 fn validate_rejects_malformed_kb_id() {
395 let cap = KnowledgeBaseCapability;
396 let cfg = json!({ "bases": ["mem_00000000000000000000000000000001"] });
397 let err = cap.validate_config(&cfg).unwrap_err();
398 assert!(err.contains("kb_"));
399 }
400
401 #[test]
402 fn validate_rejects_duplicate_bases() {
403 let cap = KnowledgeBaseCapability;
404 let cfg = json!({
405 "bases": [
406 "kb_00000000000000000000000000000001",
407 "kb_00000000000000000000000000000001"
408 ]
409 });
410 let err = cap.validate_config(&cfg).unwrap_err();
411 assert!(err.contains("duplicate"));
412 }
413
414 #[test]
415 fn validate_rejects_unknown_kind() {
416 let cap = KnowledgeBaseCapability;
417 let cfg = json!({ "kinds": ["nope"] });
418 let err = cap.validate_config(&cfg).unwrap_err();
419 assert!(err.contains("kinds"));
420 }
421
422 #[test]
423 fn uk_localization_and_schema_one_of_match_validation() {
424 let cap = KnowledgeBaseCapability;
425 assert_eq!(cap.localized_name(Some("uk-UA")), "База знань");
426 assert!(
427 cap.localized_description(Some("uk-UA"))
428 .contains("Баз знань")
429 );
430 assert!(cap.describe_schema(Some("uk")).is_some());
431 assert!(cap.describe_schema(None).is_some());
432
433 let schema = cap.config_schema().expect("config schema");
435 let consts: Vec<&str> = schema["properties"]["kinds"]["items"]["oneOf"]
436 .as_array()
437 .expect("oneOf")
438 .iter()
439 .map(|v| v["const"].as_str().expect("const"))
440 .collect();
441 assert_eq!(consts, ENTRY_KINDS);
442 for kind in consts {
443 assert!(cap.validate_config(&json!({ "kinds": [kind] })).is_ok());
444 }
445 }
446
447 use crate::traits::{KnowledgeSearchHit, KnowledgeStore, ToolContext};
450 use crate::typed_id::{DEFAULT_ORG_ID, SessionId};
451 use std::sync::Arc;
452
453 struct MockKnowledgeStore {
454 hits: Vec<KnowledgeSearchHit>,
455 }
456
457 #[async_trait]
458 impl KnowledgeStore for MockKnowledgeStore {
459 async fn search_knowledge(
460 &self,
461 _org_id: crate::typed_id::OrgId,
462 kb_public_ids: &[String],
463 _query: &str,
464 _kind: Option<&str>,
465 _tags: &[String],
466 _limit: usize,
467 ) -> crate::error::Result<Vec<KnowledgeSearchHit>> {
468 if kb_public_ids.is_empty() {
469 Ok(Vec::new())
470 } else {
471 Ok(self.hits.clone())
472 }
473 }
474 }
475
476 fn hit() -> KnowledgeSearchHit {
477 KnowledgeSearchHit {
478 id: "kbe_00000000000000000000000000000001".into(),
479 kb_id: "kb_00000000000000000000000000000001".into(),
480 title: "Orders".into(),
481 kind: "table".into(),
482 tags: vec!["sales".into()],
483 snippet: "One row per order.".into(),
484 resource: None,
485 }
486 }
487
488 #[tokio::test]
489 async fn search_tool_returns_results_from_store() {
490 let tool = SearchKnowledgeTool::new(KnowledgeBaseConfig {
491 bases: vec!["kb_00000000000000000000000000000001".into()],
492 kinds: vec![],
493 });
494 let mut ctx = ToolContext::new(SessionId::new());
495 ctx.knowledge_store = Some(Arc::new(MockKnowledgeStore { hits: vec![hit()] }));
496 ctx.org_id = Some(DEFAULT_ORG_ID);
497
498 let result = tool
499 .execute_with_context(json!({ "query": "orders" }), &ctx)
500 .await;
501 match result {
502 ToolExecutionResult::Success(v) => {
503 assert_eq!(v["count"], 1);
504 assert_eq!(
505 v["results"][0]["id"],
506 "kbe_00000000000000000000000000000001"
507 );
508 }
509 other => panic!("expected success, got {other:?}"),
510 }
511 }
512
513 #[tokio::test]
514 async fn search_tool_with_no_bases_returns_empty() {
515 let tool = SearchKnowledgeTool::new(KnowledgeBaseConfig::default());
516 let mut ctx = ToolContext::new(SessionId::new());
517 ctx.knowledge_store = Some(Arc::new(MockKnowledgeStore { hits: vec![hit()] }));
518 ctx.org_id = Some(DEFAULT_ORG_ID);
519
520 let result = tool
521 .execute_with_context(json!({ "query": "orders" }), &ctx)
522 .await;
523 match result {
524 ToolExecutionResult::Success(v) => assert_eq!(v["count"], 0),
525 other => panic!("expected success, got {other:?}"),
526 }
527 }
528
529 #[tokio::test]
530 async fn search_tool_requires_query() {
531 let tool = SearchKnowledgeTool::new(KnowledgeBaseConfig {
532 bases: vec!["kb_00000000000000000000000000000001".into()],
533 kinds: vec![],
534 });
535 let ctx = ToolContext::new(SessionId::new());
536 let result = tool.execute_with_context(json!({}), &ctx).await;
537 assert!(matches!(result, ToolExecutionResult::ToolError(_)));
538 }
539}