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 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        // No bound bases → empty result set (valid per spec).
146        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        // Explicit kind wins; otherwise fall back to the configured default filter.
162        // The store filters by a single kind. Apply a configured default only
163        // when exactly one kind is configured; with multiple, search across all
164        // kinds rather than silently honoring just the first.
165        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        // "Consult-first" tool: keep its full schema directly callable so the
215        // model invokes it rather than routing through tool-search. See
216        // specs/tool-search.md (never-defer) and specs/okf-adoption.md.
217        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                        // Values must stay in sync with ENTRY_KINDS (enforced by test).
300                        "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        // The kinds oneOf consts must be exactly the values validate_config accepts.
448        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    // --- search_knowledge tool ---
462
463    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}