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