Skip to main content

everruns_core/capabilities/
knowledge_base.rs

1//! Knowledge Base capability (EVE-423)
2//!
3//! Binds an agent or harness to one or more org-scoped Knowledge Bases. See
4//! `specs/knowledge-bases.md` for the durable design.
5//!
6//! This module registers the capability, validates the structural shape of its
7//! config (`bases[]`: `kb_`-prefixed Knowledge Base IDs; `kinds[]` from a fixed
8//! enum), and provides the agent-facing `search_knowledge` tool. Org scoping of
9//! the bound bases is enforced by the `KnowledgeStore` implementation at search
10//! time (cross-org ids are silently skipped). See specs/okf-adoption.md.
11
12use 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
20/// Stable string id for the knowledge base capability.
21pub const KNOWLEDGE_BASE_CAPABILITY_ID: &str = "knowledge_base";
22
23/// Allowed entry kinds. Must stay in sync with the SQL CHECK constraint in
24/// `crates/server/migrations/032_knowledge_bases.sql` and with
25/// `crates/server/src/domains/knowledge_bases::ENTRY_KINDS`.
26const ENTRY_KINDS: &[&str] = &["note", "table", "business", "query", "runbook"];
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct KnowledgeBaseConfig {
30    /// Knowledge Base IDs the agent can search. Empty/null = no bases bound.
31    #[serde(default)]
32    pub bases: Vec<String>,
33    /// Optional default kind filter applied when the agent does not pass
34    /// `kind` explicitly. Empty/null = no filter.
35    #[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
74/// Agent-facing tool that searches the bound Knowledge Bases. Holds the
75/// capability's config (which `bases` to search, default `kinds`), populated at
76/// collection time via `tools_with_config`. See specs/okf-adoption.md.
77pub 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        // No bound bases → empty result set (valid per spec).
132        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        // Explicit kind wins; otherwise fall back to the configured default filter.
148        // The store filters by a single kind. Apply a configured default only
149        // when exactly one kind is configured; with multiple, search across all
150        // kinds rather than silently honoring just the first.
151        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        // "Consult-first" tool: keep its full schema directly callable so the
201        // model invokes it rather than routing through tool-search. See
202        // specs/tool-search.md (never-defer) and specs/okf-adoption.md.
203        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                        // Values must stay in sync with ENTRY_KINDS (enforced by test).
286                        "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        // The kinds oneOf consts must be exactly the values validate_config accepts.
434        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    // --- search_knowledge tool ---
448
449    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}