Skip to main content

nenjo_knowledge/
tools.rs

1//! Generic knowledge tool contracts and response shaping.
2//!
3//! Runtime-specific crates provide pack discovery and resolution. The SDK owns
4//! the stable tool schemas and result payloads so builtin, project, and remote
5//! packs present the same API to agents.
6
7use std::collections::HashMap;
8use std::fmt;
9use std::str::FromStr;
10use std::sync::Arc;
11
12use anyhow::{Context, Result, anyhow};
13use async_trait::async_trait;
14use nenjo_tool_api::{Tool, ToolCategory, ToolOrigin, ToolResult, ToolSpec};
15use serde::{Deserialize, Serialize};
16use serde_json::json;
17
18use crate::{
19    KnowledgeDocFilter, KnowledgeDocManifest, KnowledgeDocNeighbor, KnowledgeDocSearchHit,
20    KnowledgePack, KnowledgePackManifest,
21};
22
23#[async_trait]
24pub trait KnowledgeRegistry: Send + Sync {
25    async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>>;
26    async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>>;
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub struct KnowledgeName(String);
31
32impl KnowledgeName {
33    pub fn parse(value: impl AsRef<str>) -> Result<Self> {
34        let value = value.as_ref().trim().to_ascii_lowercase();
35        if value.is_empty() {
36            return Err(anyhow!("knowledge name cannot be empty"));
37        }
38        if value.starts_with(['_', '-']) || value.ends_with(['_', '-']) {
39            return Err(anyhow!(
40                "knowledge name cannot start or end with a separator"
41            ));
42        }
43        if !value
44            .chars()
45            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-')
46        {
47            return Err(anyhow!(
48                "knowledge name may contain only lowercase letters, numbers, underscores, and hyphens"
49            ));
50        }
51        Ok(Self(value))
52    }
53
54    pub fn as_str(&self) -> &str {
55        &self.0
56    }
57
58    pub fn prompt_segment(&self) -> String {
59        normalize_var_segment(&self.0)
60    }
61}
62
63impl fmt::Display for KnowledgeName {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.write_str(&self.0)
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
70pub struct PackageKnowledgeName(Vec<KnowledgeName>);
71
72impl PackageKnowledgeName {
73    pub fn parse(value: impl AsRef<str>) -> Result<Self> {
74        let raw = value.as_ref().trim();
75        let raw = raw.strip_prefix('@').unwrap_or(raw);
76        let segments = raw
77            .split(['.', '/'])
78            .map(KnowledgeName::parse)
79            .collect::<Result<Vec<_>>>()?;
80        if segments.is_empty() {
81            return Err(anyhow!("package knowledge name cannot be empty"));
82        }
83        Ok(Self(segments))
84    }
85
86    pub fn prompt_path(&self) -> String {
87        self.0
88            .iter()
89            .map(KnowledgeName::prompt_segment)
90            .collect::<Vec<_>>()
91            .join(".")
92    }
93
94    pub fn selector_name(&self) -> String {
95        self.0
96            .iter()
97            .map(KnowledgeName::as_str)
98            .collect::<Vec<_>>()
99            .join(".")
100    }
101}
102
103impl fmt::Display for PackageKnowledgeName {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        f.write_str(&self.selector_name())
106    }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
110pub enum KnowledgeRef {
111    Library { pack: KnowledgeName },
112    Package { package: PackageKnowledgeName },
113    Local { pack: KnowledgeName },
114}
115
116impl KnowledgeRef {
117    pub fn library(pack: impl AsRef<str>) -> Result<Self> {
118        Ok(Self::Library {
119            pack: KnowledgeName::parse(pack)?,
120        })
121    }
122
123    pub fn package(package: impl AsRef<str>) -> Result<Self> {
124        Ok(Self::Package {
125            package: PackageKnowledgeName::parse(package)?,
126        })
127    }
128
129    pub fn local(pack: impl AsRef<str>) -> Result<Self> {
130        Ok(Self::Local {
131            pack: KnowledgeName::parse(pack)?,
132        })
133    }
134
135    pub fn selector(&self) -> String {
136        self.to_string()
137    }
138
139    pub fn prompt_prefix(&self) -> String {
140        match self {
141            Self::Library { pack } => format!("lib.{}", pack.prompt_segment()),
142            Self::Package { package } => format!("pkg.{}", package.prompt_path()),
143            Self::Local { pack } => format!("local.{}", pack.prompt_segment()),
144        }
145    }
146}
147
148impl fmt::Display for KnowledgeRef {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        match self {
151            Self::Library { pack } => write!(f, "lib:{pack}"),
152            Self::Package { package } => write!(f, "pkg:{package}"),
153            Self::Local { pack } => write!(f, "local:{pack}"),
154        }
155    }
156}
157
158impl FromStr for KnowledgeRef {
159    type Err = anyhow::Error;
160
161    fn from_str(value: &str) -> Result<Self, Self::Err> {
162        let value = value.trim();
163        if let Some(pack) = value.strip_prefix("lib:") {
164            return Self::library(pack);
165        }
166        if let Some(pack) = value.strip_prefix("local:") {
167            return Self::local(pack);
168        }
169        if let Some(package) = value.strip_prefix("pkg:") {
170            return Self::package(package);
171        }
172        Err(anyhow!(
173            "invalid knowledge selector '{value}'; expected lib:<pack>, pkg:<package>, or local:<pack>"
174        ))
175    }
176}
177
178#[derive(Clone)]
179pub struct KnowledgePackEntry {
180    knowledge_ref: KnowledgeRef,
181    pack: Arc<dyn KnowledgePack>,
182}
183
184impl KnowledgePackEntry {
185    pub fn new(knowledge_ref: KnowledgeRef, pack: impl KnowledgePack + 'static) -> Self {
186        Self {
187            knowledge_ref,
188            pack: Arc::new(pack),
189        }
190    }
191
192    pub fn library(pack_name: impl AsRef<str>, pack: impl KnowledgePack + 'static) -> Result<Self> {
193        Ok(Self::new(KnowledgeRef::library(pack_name)?, pack))
194    }
195
196    pub fn package(
197        package_name: impl AsRef<str>,
198        pack: impl KnowledgePack + 'static,
199    ) -> Result<Self> {
200        Ok(Self::new(KnowledgeRef::package(package_name)?, pack))
201    }
202
203    pub fn local(pack_name: impl AsRef<str>, pack: impl KnowledgePack + 'static) -> Result<Self> {
204        Ok(Self::new(KnowledgeRef::local(pack_name)?, pack))
205    }
206
207    pub fn knowledge_ref(&self) -> &KnowledgeRef {
208        &self.knowledge_ref
209    }
210
211    pub fn selector(&self) -> String {
212        self.knowledge_ref.selector()
213    }
214
215    pub fn pack(&self) -> &Arc<dyn KnowledgePack> {
216        &self.pack
217    }
218
219    fn into_parts(self) -> (KnowledgeRef, Arc<dyn KnowledgePack>) {
220        (self.knowledge_ref, self.pack)
221    }
222}
223
224#[derive(Clone, Default)]
225pub struct StaticKnowledgeRegistry {
226    packs: Arc<HashMap<String, Arc<dyn KnowledgePack>>>,
227}
228
229impl StaticKnowledgeRegistry {
230    pub fn new() -> Self {
231        Self::default()
232    }
233
234    pub fn with_pack(mut self, selector: impl Into<String>, pack: Arc<dyn KnowledgePack>) -> Self {
235        Arc::make_mut(&mut self.packs).insert(selector.into(), pack);
236        self
237    }
238
239    pub fn with_entry(self, entry: KnowledgePackEntry) -> Self {
240        let (knowledge_ref, pack) = entry.into_parts();
241        self.with_pack(knowledge_ref.selector(), pack)
242    }
243
244    pub fn with_entries(mut self, entries: impl IntoIterator<Item = KnowledgePackEntry>) -> Self {
245        for entry in entries {
246            self = self.with_entry(entry);
247        }
248        self
249    }
250
251    pub fn is_empty(&self) -> bool {
252        self.packs.is_empty()
253    }
254}
255
256#[derive(Clone, Default)]
257pub struct CompositeKnowledgeRegistry {
258    library: StaticKnowledgeRegistry,
259    package: StaticKnowledgeRegistry,
260    local: StaticKnowledgeRegistry,
261}
262
263impl CompositeKnowledgeRegistry {
264    pub fn new() -> Self {
265        Self::default()
266    }
267
268    pub fn with_entry(mut self, entry: KnowledgePackEntry) -> Self {
269        match entry.knowledge_ref() {
270            KnowledgeRef::Library { .. } => {
271                self.library = self.library.with_entry(entry);
272            }
273            KnowledgeRef::Package { .. } => {
274                self.package = self.package.with_entry(entry);
275            }
276            KnowledgeRef::Local { .. } => {
277                self.local = self.local.with_entry(entry);
278            }
279        }
280        self
281    }
282
283    pub fn with_entries(mut self, entries: impl IntoIterator<Item = KnowledgePackEntry>) -> Self {
284        for entry in entries {
285            self = self.with_entry(entry);
286        }
287        self
288    }
289
290    pub fn is_empty(&self) -> bool {
291        self.library.is_empty() && self.package.is_empty() && self.local.is_empty()
292    }
293
294    fn static_registry_for_selector(&self, selector: &str) -> Result<&StaticKnowledgeRegistry> {
295        match KnowledgeRef::from_str(selector)? {
296            KnowledgeRef::Library { .. } => Ok(&self.library),
297            KnowledgeRef::Package { .. } => Ok(&self.package),
298            KnowledgeRef::Local { .. } => Ok(&self.local),
299        }
300    }
301}
302
303#[async_trait]
304impl KnowledgeRegistry for CompositeKnowledgeRegistry {
305    async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>> {
306        let mut packs = Vec::new();
307        packs.extend(self.library.list_packs().await?);
308        packs.extend(self.package.list_packs().await?);
309        packs.extend(self.local.list_packs().await?);
310        packs.sort_by(|a, b| a.pack.cmp(&b.pack));
311        Ok(packs)
312    }
313
314    async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>> {
315        self.static_registry_for_selector(selector)?
316            .resolve_pack(selector)
317            .await
318    }
319}
320
321#[async_trait]
322impl KnowledgeRegistry for StaticKnowledgeRegistry {
323    async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>> {
324        let mut packs = self
325            .packs
326            .iter()
327            .map(|(selector, pack)| KnowledgePackSummary::new(selector, pack.manifest()))
328            .collect::<Vec<_>>();
329        packs.sort_by(|a, b| a.pack.cmp(&b.pack));
330        Ok(packs)
331    }
332
333    async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>> {
334        self.packs
335            .get(selector)
336            .cloned()
337            .ok_or_else(|| anyhow!("unknown knowledge pack '{selector}'"))
338    }
339}
340
341#[derive(Debug, Clone, Serialize)]
342pub struct KnowledgePackSummary {
343    /// Canonical pack selector to pass as the `pack` argument to knowledge tools.
344    pub selector: String,
345    /// Backwards-compatible alias for `selector`.
346    pub pack: String,
347    pub pack_id: String,
348    pub version: String,
349    pub root_uri: String,
350    pub document_count: usize,
351}
352
353impl KnowledgePackSummary {
354    pub fn new(pack: impl Into<String>, manifest: &dyn KnowledgePackManifest) -> Self {
355        let selector = pack.into();
356        Self {
357            pack: selector.clone(),
358            selector,
359            pack_id: manifest.pack_id().to_string(),
360            version: manifest.version().to_string(),
361            root_uri: manifest.root_uri().to_string(),
362            document_count: manifest.docs().len(),
363        }
364    }
365}
366
367#[derive(Debug, Clone, Deserialize)]
368pub struct KnowledgeReadArgs {
369    pub pack: String,
370    pub selector: String,
371}
372
373#[derive(Debug, Clone, Deserialize)]
374pub struct KnowledgeSearchArgs {
375    pub pack: String,
376    pub query: String,
377    #[serde(flatten)]
378    pub filter: KnowledgeFilterArgs,
379}
380
381#[derive(Debug, Clone, Deserialize)]
382pub struct KnowledgeNeighborArgs {
383    pub pack: String,
384    pub selector: String,
385    pub edge_type: Option<String>,
386}
387
388#[derive(Debug, Clone, Default, Deserialize)]
389pub struct KnowledgeFilterArgs {
390    #[serde(default)]
391    pub tags: Vec<String>,
392    pub kind: Option<String>,
393    pub selector_prefix: Option<String>,
394    pub related_to: Option<String>,
395    pub edge_type: Option<String>,
396}
397
398#[derive(Debug, Clone, Serialize)]
399pub struct KnowledgeDocMetadataResult {
400    /// Canonical pack selector to pass as the `pack` argument to knowledge tools.
401    pub pack: String,
402    /// Stable document identifier within the pack.
403    pub id: String,
404    /// Agent-visible selector used for lookup and traversal.
405    pub selector: String,
406    /// Human-readable title.
407    pub title: String,
408    /// Short summary used for selection.
409    pub summary: String,
410    /// Open-ended document category.
411    pub kind: String,
412    /// Lightweight classification labels.
413    pub tags: Vec<String>,
414    /// Outbound graph edge pointers. Call `list_knowledge_neighbors` to expand them.
415    pub related: Vec<KnowledgeDocRelatedResult>,
416}
417
418#[derive(Debug, Clone, Serialize)]
419pub struct KnowledgeDocRelatedResult {
420    #[serde(rename = "type")]
421    pub edge_type: String,
422    /// Target document id or path.
423    pub target: String,
424}
425
426#[derive(Debug, Clone, Serialize)]
427pub struct KnowledgeDocReadResult {
428    /// Slim document metadata.
429    pub document: KnowledgeDocMetadataResult,
430    /// Full document body.
431    pub content: String,
432}
433
434#[derive(Debug, Clone, Serialize)]
435pub struct KnowledgeDocSearchResult {
436    /// Slim document metadata for the matched document.
437    pub document: KnowledgeDocMetadataResult,
438    /// Simple relevance score derived from metadata matches.
439    pub score: usize,
440    /// Metadata fields that matched the query.
441    pub matched: Vec<String>,
442}
443
444#[derive(Debug, Clone, Serialize)]
445pub struct KnowledgeDocNeighborsResult {
446    /// Source document metadata.
447    pub document: KnowledgeDocMetadataResult,
448    /// Resolved outbound neighbor edges.
449    pub edges: Vec<KnowledgeDocNeighborEdgeResult>,
450}
451
452#[derive(Debug, Clone, Serialize)]
453pub struct KnowledgeDocNeighborEdgeResult {
454    #[serde(rename = "type")]
455    pub edge_type: String,
456    /// Resolved target document metadata.
457    pub target: KnowledgeDocMetadataResult,
458}
459
460pub fn knowledge_filter(filter: KnowledgeFilterArgs) -> Result<KnowledgeDocFilter> {
461    Ok(KnowledgeDocFilter {
462        tags: filter.tags,
463        kind: parse_knowledge_enum(filter.kind)?,
464        selector_prefix: filter.selector_prefix,
465        related_to: filter.related_to,
466        edge_type: parse_knowledge_enum(filter.edge_type)?,
467    })
468}
469
470pub fn parse_knowledge_enum<T>(value: Option<String>) -> Result<Option<T>>
471where
472    T: serde::de::DeserializeOwned,
473{
474    value
475        .map(|value| {
476            serde_json::from_value(serde_json::Value::String(value.to_lowercase()))
477                .with_context(|| "invalid knowledge filter value")
478        })
479        .transpose()
480}
481
482pub fn knowledge_document_metadata(
483    pack: impl Into<String>,
484    doc: &KnowledgeDocManifest,
485) -> KnowledgeDocMetadataResult {
486    KnowledgeDocMetadataResult {
487        pack: pack.into(),
488        id: doc.id.clone(),
489        selector: doc.selector.clone(),
490        title: doc.title.clone(),
491        summary: doc.summary.clone(),
492        kind: doc.kind.as_str().to_string(),
493        tags: doc.tags.clone(),
494        related: doc
495            .related
496            .iter()
497            .map(|edge| KnowledgeDocRelatedResult {
498                edge_type: edge.edge_type.as_str().to_string(),
499                target: edge.target.clone(),
500            })
501            .collect(),
502    }
503}
504
505pub fn knowledge_search_result(
506    pack: impl Into<String>,
507    hit: KnowledgeDocSearchHit,
508) -> KnowledgeDocSearchResult {
509    KnowledgeDocSearchResult {
510        document: knowledge_document_metadata(pack, &hit.document),
511        score: hit.score,
512        matched: hit.matched,
513    }
514}
515
516pub fn knowledge_neighbors_result(
517    pack: impl Into<String> + Clone,
518    neighbors: KnowledgeDocNeighbor,
519) -> KnowledgeDocNeighborsResult {
520    KnowledgeDocNeighborsResult {
521        document: knowledge_document_metadata(pack.clone(), &neighbors.document),
522        edges: neighbors
523            .edges
524            .into_iter()
525            .map(|edge| KnowledgeDocNeighborEdgeResult {
526                edge_type: edge.edge_type.as_str().to_string(),
527                target: knowledge_document_metadata(pack.clone(), &edge.target),
528            })
529            .collect(),
530    }
531}
532
533pub fn knowledge_document_metadata_vars(
534    knowledge_ref: &KnowledgeRef,
535    pack: &dyn KnowledgePack,
536) -> HashMap<String, String> {
537    let mut vars = HashMap::new();
538    for doc in pack.manifest().docs() {
539        let metadata = doc_metadata(knowledge_ref, doc);
540        vars.insert(
541            knowledge_document_var_key(knowledge_ref, doc),
542            metadata.clone(),
543        );
544        for key in knowledge_document_alias_var_keys(knowledge_ref, doc) {
545            vars.entry(key).or_insert_with(|| metadata.clone());
546        }
547    }
548    vars
549}
550
551pub fn knowledge_pack_prompt_vars(
552    knowledge_ref: &KnowledgeRef,
553    pack: &dyn KnowledgePack,
554) -> HashMap<String, String> {
555    let prefix = knowledge_ref.prompt_prefix();
556    let mut vars = HashMap::new();
557    vars.insert(prefix, knowledge_pack_summary(knowledge_ref, pack));
558    vars.extend(knowledge_document_metadata_vars(knowledge_ref, pack));
559    vars
560}
561
562pub fn knowledge_pack_summary(knowledge_ref: &KnowledgeRef, pack: &dyn KnowledgePack) -> String {
563    let manifest = pack.manifest();
564    let selector = knowledge_ref.selector();
565    let namespace = match knowledge_ref {
566        KnowledgeRef::Library { .. } => "lib",
567        KnowledgeRef::Package { .. } => "pkg",
568        KnowledgeRef::Local { .. } => "local",
569    };
570    let ctx = KnowledgePackSummaryContext {
571        selector: selector.as_str(),
572        namespace,
573        name: manifest.pack_id(),
574        root: manifest.root_uri(),
575        usage: "Use the knowledge tools to search, inspect metadata, expand graph neighbors, and read documents from this pack when relevant.",
576        docs: manifest
577            .docs()
578            .iter()
579            .map(|doc| KnowledgeDocumentSummaryContext {
580                selector: doc.selector.as_str(),
581                id: doc.id.as_str(),
582                kind: doc.kind.as_str(),
583                title: doc.title.as_str(),
584                summary: doc.summary.as_str(),
585                related: doc
586                    .related
587                    .iter()
588                    .map(|edge| KnowledgeDocumentRelatedSummaryContext {
589                        edge_type: edge.edge_type.as_str(),
590                        target: edge.target.as_str(),
591                    })
592                    .collect(),
593            })
594            .collect(),
595    };
596
597    nenjo_xml::to_xml_pretty(&ctx, 2)
598}
599
600#[derive(Debug, Serialize)]
601#[serde(rename = "knowledge_pack")]
602struct KnowledgePackSummaryContext<'a> {
603    #[serde(rename = "@selector")]
604    selector: &'a str,
605    #[serde(rename = "@namespace")]
606    namespace: &'a str,
607    #[serde(rename = "@name")]
608    name: &'a str,
609    #[serde(rename = "@root")]
610    root: &'a str,
611    usage: &'a str,
612    #[serde(rename = "doc")]
613    docs: Vec<KnowledgeDocumentSummaryContext<'a>>,
614}
615
616#[derive(Debug, Serialize)]
617#[serde(rename = "doc")]
618struct KnowledgeDocumentSummaryContext<'a> {
619    #[serde(rename = "@selector")]
620    selector: &'a str,
621    #[serde(rename = "@id")]
622    id: &'a str,
623    #[serde(rename = "@kind")]
624    kind: &'a str,
625    title: &'a str,
626    summary: &'a str,
627    #[serde(rename = "related", skip_serializing_if = "Vec::is_empty", default)]
628    related: Vec<KnowledgeDocumentRelatedSummaryContext<'a>>,
629}
630
631#[derive(Debug, Serialize)]
632#[serde(rename = "related")]
633struct KnowledgeDocumentRelatedSummaryContext<'a> {
634    #[serde(rename = "@type")]
635    edge_type: &'a str,
636    #[serde(rename = "@target")]
637    target: &'a str,
638}
639
640pub fn knowledge_document_var_key(
641    knowledge_ref: &KnowledgeRef,
642    doc: &KnowledgeDocManifest,
643) -> String {
644    let pack_prefix = knowledge_ref.prompt_prefix();
645    let selector = prompt_doc_selector(doc);
646    let path = selector
647        .strip_suffix(".md")
648        .unwrap_or(selector.as_str())
649        .split(['.', '/'])
650        .filter(|segment| !segment.is_empty())
651        .map(normalize_var_segment)
652        .filter(|segment| !segment.is_empty())
653        .collect::<Vec<_>>()
654        .join(".");
655    if path.is_empty() {
656        pack_prefix
657    } else {
658        format!("{pack_prefix}.{path}")
659    }
660}
661
662fn knowledge_document_alias_var_keys(
663    knowledge_ref: &KnowledgeRef,
664    doc: &KnowledgeDocManifest,
665) -> Vec<String> {
666    let mut keys = Vec::new();
667    let pack_prefix = knowledge_ref.prompt_prefix();
668    let selector = prompt_doc_selector(doc);
669    let Some((parent, _leaf)) = selector
670        .strip_suffix(".md")
671        .unwrap_or(selector.as_str())
672        .rsplit_once(['.', '/'])
673    else {
674        return keys;
675    };
676    let parent = parent
677        .split(['.', '/'])
678        .filter(|segment| !segment.is_empty())
679        .map(normalize_var_segment)
680        .filter(|segment| !segment.is_empty())
681        .collect::<Vec<_>>()
682        .join(".");
683
684    if let Some(stripped) = doc.id.strip_prefix("nenjo.") {
685        let id_segments = stripped
686            .split('.')
687            .map(normalize_var_segment)
688            .filter(|segment| !segment.is_empty())
689            .collect::<Vec<_>>();
690        if id_segments.len() >= 2
691            && id_segments
692                .first()
693                .is_some_and(|segment| segment == &parent)
694        {
695            let basename = id_segments[1..].join("_");
696            keys.push(format!("{pack_prefix}.{parent}.nenjo_{basename}"));
697        }
698    }
699
700    keys
701}
702
703fn normalize_var_segment(segment: &str) -> String {
704    let mut normalized = String::new();
705    let mut last_was_underscore = false;
706    for ch in segment.chars() {
707        let ch = ch.to_ascii_lowercase();
708        if ch.is_ascii_alphanumeric() {
709            normalized.push(ch);
710            last_was_underscore = false;
711        } else if !last_was_underscore {
712            normalized.push('_');
713            last_was_underscore = true;
714        }
715    }
716    normalized.trim_matches('_').to_string()
717}
718
719#[derive(Debug, Serialize)]
720#[serde(rename = "knowledge_doc")]
721struct KnowledgeDocMetadataContext<'a> {
722    #[serde(rename = "@pack")]
723    pack: &'a str,
724    #[serde(rename = "@selector")]
725    selector: &'a str,
726    #[serde(rename = "@title")]
727    title: &'a str,
728    #[serde(rename = "@kind")]
729    kind: &'a str,
730    summary: &'a str,
731    #[serde(skip_serializing_if = "Vec::is_empty", default)]
732    tags: Vec<&'a str>,
733    #[serde(rename = "related", skip_serializing_if = "Vec::is_empty", default)]
734    related: Vec<KnowledgeDocumentRelatedSummaryContext<'a>>,
735}
736
737fn doc_metadata(knowledge_ref: &KnowledgeRef, doc: &KnowledgeDocManifest) -> String {
738    let selector = prompt_doc_selector(doc);
739    let pack = knowledge_ref.selector();
740    let ctx = KnowledgeDocMetadataContext {
741        pack: &pack,
742        selector: &selector,
743        title: &doc.title,
744        summary: &doc.summary,
745        kind: doc.kind.as_str(),
746        tags: doc.tags.iter().map(String::as_str).collect(),
747        related: doc
748            .related
749            .iter()
750            .map(|edge| KnowledgeDocumentRelatedSummaryContext {
751                edge_type: edge.edge_type.as_str(),
752                target: edge.target.as_str(),
753            })
754            .collect(),
755    };
756    nenjo_xml::to_xml_pretty(&ctx, 2)
757}
758
759fn prompt_doc_selector(doc: &KnowledgeDocManifest) -> String {
760    if doc.selector.starts_with("library://") {
761        doc.selector
762            .splitn(4, '/')
763            .nth(3)
764            .unwrap_or(&doc.selector)
765            .to_string()
766    } else {
767        doc.selector.clone()
768    }
769}
770
771fn pack_schema() -> serde_json::Value {
772    json!({
773        "type": "string",
774        "description": "Canonical knowledge pack selector. Use exactly the selector returned by list_knowledge_packs or the pack attribute in seeded knowledge metadata, such as pkg:<source>.<repo>.<package>.<pack>."
775    })
776}
777
778fn knowledge_filter_schema(
779    extra_properties: Option<serde_json::Value>,
780    required: &[&str],
781) -> serde_json::Value {
782    let mut properties = json!({
783        "pack": pack_schema(),
784        "tags": {
785            "type": "array",
786            "items": { "type": "string" },
787            "description": "Optional tags that all returned docs must have"
788        },
789        "kind": {
790            "type": "string",
791            "description": "Optional kind filter such as guide or reference"
792        },
793        "selector_prefix": {
794            "type": "string",
795            "description": "Optional virtual or pack-relative selector prefix"
796        },
797        "related_to": {
798            "type": "string",
799            "description": "Optional selector of a document this result must be related to"
800        },
801        "edge_type": {
802            "type": "string",
803            "description": "Optional relationship type used with related_to or neighbors"
804        }
805    });
806
807    if let Some(extra) = extra_properties
808        && let Some(map) = properties.as_object_mut()
809        && let Some(extra_map) = extra.as_object()
810    {
811        for (key, value) in extra_map {
812            map.insert(key.clone(), value.clone());
813        }
814    }
815
816    json!({
817        "type": "object",
818        "properties": properties,
819        "required": required,
820        "additionalProperties": false
821    })
822}
823
824fn knowledge_lookup_schema() -> serde_json::Value {
825    json!({
826        "type": "object",
827        "properties": {
828            "pack": pack_schema(),
829            "selector": {
830                "type": "string",
831                "description": "Document selector or id within the selected pack"
832            }
833        },
834        "required": ["pack", "selector"],
835        "additionalProperties": false
836    })
837}
838
839pub fn knowledge_tools() -> Vec<ToolSpec> {
840    vec![
841        ToolSpec {
842            name: "list_knowledge_packs".into(),
843            description: "List locally available knowledge packs. Copy the returned selector value into the pack argument for read_knowledge_doc, search_knowledge, and list_knowledge_neighbors.".into(),
844            parameters: json!({
845                "type": "object",
846                "properties": {},
847                "additionalProperties": false
848            }),
849            category: ToolCategory::Read,
850        },
851        ToolSpec {
852            name: "read_knowledge_doc".into(),
853            description: "Read one full document body from a knowledge pack by path.".into(),
854            parameters: knowledge_lookup_schema(),
855            category: ToolCategory::Read,
856        },
857        ToolSpec {
858            name: "search_knowledge".into(),
859            description: "Search a knowledge pack and return candidate document metadata without loading document bodies.".into(),
860            parameters: knowledge_filter_schema(
861                Some(json!({
862                    "query": {
863                        "type": "string",
864                        "description": "Search query, path, title, tag, or summary"
865                    }
866                })),
867                &["pack", "query"],
868            ),
869            category: ToolCategory::Read,
870        },
871        ToolSpec {
872            name: "list_knowledge_neighbors".into(),
873            description: "List outbound graph neighbors for one document in a knowledge pack.".into(),
874            parameters: json!({
875                "type": "object",
876                "properties": {
877                    "pack": pack_schema(),
878                    "selector": {
879                        "type": "string",
880                        "description": "Document selector or id within the selected pack"
881                    },
882                    "edge_type": {
883                        "type": "string",
884                        "description": "Optional relationship type filter such as references or depends_on"
885                    }
886                },
887                "required": ["pack", "selector"],
888                "additionalProperties": false
889            }),
890            category: ToolCategory::Read,
891        },
892    ]
893}
894
895pub fn knowledge_toolbelt(registry: Arc<dyn KnowledgeRegistry>) -> Vec<Arc<dyn Tool>> {
896    knowledge_tools()
897        .into_iter()
898        .map(|spec| Arc::new(KnowledgeTool::new(spec, registry.clone())) as Arc<dyn Tool>)
899        .collect()
900}
901
902struct KnowledgeTool {
903    spec: ToolSpec,
904    registry: Arc<dyn KnowledgeRegistry>,
905}
906
907impl KnowledgeTool {
908    fn new(spec: ToolSpec, registry: Arc<dyn KnowledgeRegistry>) -> Self {
909        Self { spec, registry }
910    }
911}
912
913#[async_trait]
914impl Tool for KnowledgeTool {
915    fn name(&self) -> &str {
916        &self.spec.name
917    }
918
919    fn description(&self) -> &str {
920        &self.spec.description
921    }
922
923    fn parameters_schema(&self) -> serde_json::Value {
924        self.spec.parameters.clone()
925    }
926
927    fn category(&self) -> ToolCategory {
928        self.spec.category
929    }
930
931    fn origin(&self) -> ToolOrigin {
932        ToolOrigin::Platform
933    }
934
935    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
936        let output = match self.name() {
937            "list_knowledge_packs" => serde_json::to_value(self.registry.list_packs().await?)?,
938            "read_knowledge_doc" => {
939                let args: KnowledgeReadArgs = serde_json::from_value(args)?;
940                let pack = self.registry.resolve_pack(&args.pack).await?;
941                let doc = pack.read_doc(&args.selector).ok_or_else(|| {
942                    anyhow!(
943                        "knowledge document '{}' not found in pack '{}'",
944                        args.selector,
945                        args.pack
946                    )
947                })?;
948                serde_json::to_value(KnowledgeDocReadResult {
949                    document: knowledge_document_metadata(args.pack, &doc.manifest),
950                    content: doc.content,
951                })?
952            }
953            "search_knowledge" => {
954                let args: KnowledgeSearchArgs = serde_json::from_value(args)?;
955                let pack = self.registry.resolve_pack(&args.pack).await?;
956                let filter = knowledge_filter(args.filter)?;
957                let hits = pack
958                    .search(&args.query, filter)
959                    .into_iter()
960                    .map(|hit| knowledge_search_result(args.pack.clone(), hit))
961                    .collect::<Vec<_>>();
962                serde_json::to_value(hits)?
963            }
964            "list_knowledge_neighbors" => {
965                let args: KnowledgeNeighborArgs = serde_json::from_value(args)?;
966                let pack = self.registry.resolve_pack(&args.pack).await?;
967                let edge_type = parse_knowledge_enum(args.edge_type)?;
968                let neighbors = pack.neighbors(&args.selector, edge_type).ok_or_else(|| {
969                    anyhow!(
970                        "knowledge document '{}' not found in pack '{}'",
971                        args.selector,
972                        args.pack
973                    )
974                })?;
975                serde_json::to_value(knowledge_neighbors_result(args.pack, neighbors))?
976            }
977            name => return Err(anyhow!("unknown knowledge tool '{name}'")),
978        };
979
980        Ok(ToolResult {
981            success: true,
982            output: serde_json::to_string_pretty(&output)?,
983            error: None,
984        })
985    }
986}
987
988#[cfg(test)]
989mod tests {
990    use std::borrow::Cow;
991    use std::future::Future;
992    use std::task::{Context, Poll, Waker};
993
994    use super::{
995        CompositeKnowledgeRegistry, KnowledgeDocReadResult, KnowledgePackEntry, KnowledgeRef,
996        KnowledgeRegistry, knowledge_document_var_key, knowledge_neighbors_result,
997        knowledge_search_result, knowledge_tools,
998    };
999    use crate::{
1000        KnowledgeDocEdge, KnowledgeDocEdgeType, KnowledgeDocKind, KnowledgeDocManifest,
1001        KnowledgePack, KnowledgePackManifest, KnowledgePackManifestData,
1002    };
1003    use serde_json::json;
1004
1005    struct TestPack {
1006        manifest: KnowledgePackManifestData,
1007    }
1008
1009    impl KnowledgePack for TestPack {
1010        fn manifest(&self) -> &dyn KnowledgePackManifest {
1011            &self.manifest
1012        }
1013
1014        fn doc_content(&self, manifest: &KnowledgeDocManifest) -> Option<Cow<'_, str>> {
1015            Some(Cow::Owned(format!("body for {}", manifest.title)))
1016        }
1017    }
1018
1019    fn block_on<F: Future>(future: F) -> F::Output {
1020        let waker = Waker::noop();
1021        let mut context = Context::from_waker(waker);
1022        let mut future = Box::pin(future);
1023        match future.as_mut().poll(&mut context) {
1024            Poll::Ready(output) => output,
1025            Poll::Pending => panic!("test future unexpectedly yielded"),
1026        }
1027    }
1028
1029    fn test_doc(
1030        id: &str,
1031        path: &str,
1032        title: &str,
1033        related: Vec<KnowledgeDocEdge>,
1034    ) -> KnowledgeDocManifest {
1035        KnowledgeDocManifest {
1036            id: id.into(),
1037            selector: path.into(),
1038            source_path: path.trim_start_matches("library://test/").into(),
1039            title: title.into(),
1040            summary: format!("{title} summary"),
1041            kind: KnowledgeDocKind::new("routing-guide"),
1042            tags: vec!["core".into()],
1043            related,
1044            updated_at: String::new(),
1045        }
1046    }
1047
1048    fn test_pack() -> TestPack {
1049        TestPack {
1050            manifest: KnowledgePackManifestData {
1051                pack_id: "test".into(),
1052                version: "1".into(),
1053                schema_version: 1,
1054                root_uri: "library://test/".into(),
1055                content_hash: String::new(),
1056                docs: vec![
1057                    test_doc(
1058                        "root",
1059                        "library://test/root.md",
1060                        "Root",
1061                        vec![KnowledgeDocEdge {
1062                            edge_type: KnowledgeDocEdgeType::DependsOn,
1063                            target: "library://test/leaf.md".into(),
1064                            description: Some("root to leaf".into()),
1065                        }],
1066                    ),
1067                    test_doc(
1068                        "leaf",
1069                        "library://test/leaf.md",
1070                        "Leaf",
1071                        vec![KnowledgeDocEdge {
1072                            edge_type: KnowledgeDocEdgeType::References,
1073                            target: "library://test/root.md".into(),
1074                            description: Some("reverse edge".into()),
1075                        }],
1076                    ),
1077                ],
1078            },
1079        }
1080    }
1081
1082    #[test]
1083    fn composite_registry_routes_builtin_knowledge_namespaces() {
1084        block_on(async {
1085            let registry = CompositeKnowledgeRegistry::new()
1086                .with_entry(KnowledgePackEntry::library("docs", test_pack()).unwrap())
1087                .with_entry(KnowledgePackEntry::package("nenjo/core", test_pack()).unwrap())
1088                .with_entry(KnowledgePackEntry::local("scratch", test_pack()).unwrap());
1089
1090            let packs = registry.list_packs().await.unwrap();
1091            let selectors = packs
1092                .iter()
1093                .map(|pack| pack.selector.as_str())
1094                .collect::<Vec<_>>();
1095            assert_eq!(
1096                selectors,
1097                vec!["lib:docs", "local:scratch", "pkg:nenjo.core"]
1098            );
1099
1100            assert_eq!(
1101                registry
1102                    .resolve_pack("lib:docs")
1103                    .await
1104                    .unwrap()
1105                    .manifest()
1106                    .pack_id(),
1107                "test"
1108            );
1109            assert!(registry.resolve_pack("pkg:nenjo.core").await.is_ok());
1110            assert!(registry.resolve_pack("local:scratch").await.is_ok());
1111        });
1112    }
1113
1114    #[test]
1115    fn default_knowledge_tool_registry_exposes_graph_first_tools_only() {
1116        let names = knowledge_tools()
1117            .into_iter()
1118            .map(|tool| tool.name)
1119            .collect::<Vec<_>>();
1120
1121        assert_eq!(
1122            names,
1123            vec![
1124                "list_knowledge_packs",
1125                "read_knowledge_doc",
1126                "search_knowledge",
1127                "list_knowledge_neighbors",
1128            ]
1129        );
1130    }
1131
1132    #[test]
1133    fn knowledge_pack_summary_returns_selector_for_tool_calls() {
1134        let pack = test_pack();
1135        let summary = super::KnowledgePackSummary::new(
1136            "pkg:nenjo-ai.packages.knowledge.core",
1137            pack.manifest(),
1138        );
1139
1140        assert_eq!(summary.selector, "pkg:nenjo-ai.packages.knowledge.core");
1141        assert_eq!(summary.pack, summary.selector);
1142    }
1143
1144    #[test]
1145    fn pack_prompt_summary_includes_compact_related_edges() {
1146        let pack = TestPack {
1147            manifest: KnowledgePackManifestData {
1148                pack_id: "test".into(),
1149                version: "1".into(),
1150                schema_version: 1,
1151                root_uri: "file:///tmp/test/".into(),
1152                content_hash: String::new(),
1153                docs: vec![
1154                    test_doc(
1155                        "root",
1156                        "docs/root.md",
1157                        "Root",
1158                        vec![KnowledgeDocEdge {
1159                            edge_type: KnowledgeDocEdgeType::DependsOn,
1160                            target: "docs/leaf.md".into(),
1161                            description: Some("root to leaf".into()),
1162                        }],
1163                    ),
1164                    test_doc(
1165                        "leaf",
1166                        "docs/leaf.md",
1167                        "Leaf",
1168                        vec![KnowledgeDocEdge {
1169                            edge_type: KnowledgeDocEdgeType::References,
1170                            target: "docs/root.md".into(),
1171                            description: Some("reverse edge".into()),
1172                        }],
1173                    ),
1174                ],
1175            },
1176        };
1177        let knowledge_ref = KnowledgeRef::local("test").unwrap();
1178        let summary = super::knowledge_pack_summary(&knowledge_ref, &pack);
1179
1180        assert!(summary.contains(r#"selector="local:test""#));
1181        assert!(summary.contains(r#"<related type="depends_on" target="docs/leaf.md""#));
1182        assert!(summary.contains(r#"<related type="references" target="docs/root.md""#));
1183        assert!(!summary.contains("root to leaf"));
1184        assert!(!summary.contains("reverse edge"));
1185    }
1186
1187    #[test]
1188    fn document_metadata_prompt_var_includes_related_edges() {
1189        let doc = test_doc(
1190            "root",
1191            "docs/root.md",
1192            "Root",
1193            vec![KnowledgeDocEdge {
1194                edge_type: KnowledgeDocEdgeType::DependsOn,
1195                target: "docs/leaf.md".into(),
1196                description: Some("root to leaf".into()),
1197            }],
1198        );
1199        let knowledge_ref = KnowledgeRef::local("test").unwrap();
1200        let metadata = super::doc_metadata(&knowledge_ref, &doc);
1201
1202        assert!(metadata.contains(r#"pack="local:test""#));
1203        assert!(metadata.contains(r#"selector="docs/root.md""#));
1204        assert!(metadata.contains(r#"<related type="depends_on" target="docs/leaf.md""#));
1205        assert!(!metadata.contains("root to leaf"));
1206    }
1207
1208    #[test]
1209    fn neighbor_traversal_returns_outbound_edges_with_slim_target_metadata() {
1210        let pack = test_pack();
1211        let result = pack
1212            .neighbors("root", None)
1213            .map(|neighbors| knowledge_neighbors_result("lib:test", neighbors))
1214            .expect("root neighbors");
1215        let value = serde_json::to_value(result).unwrap();
1216
1217        assert_eq!(value["document"]["selector"], "library://test/root.md");
1218        assert_eq!(value["document"]["related"][0]["type"], "depends_on");
1219        assert_eq!(
1220            value["document"]["related"][0]["target"],
1221            "library://test/leaf.md"
1222        );
1223        assert_eq!(value["edges"].as_array().unwrap().len(), 1);
1224        assert_eq!(value["edges"][0]["type"], "depends_on");
1225        assert_eq!(
1226            value["edges"][0]["target"]["selector"],
1227            "library://test/leaf.md"
1228        );
1229        assert_eq!(value["edges"][0]["target"]["kind"], "routing_guide");
1230        assert!(value["edges"][0]["target"].get("source_path").is_none());
1231        assert!(value["edges"][0].get("note").is_none());
1232    }
1233
1234    #[test]
1235    fn search_returns_slim_metadata_without_content() {
1236        let pack = test_pack();
1237        let value = serde_json::to_value(
1238            pack.search("Leaf", Default::default())
1239                .into_iter()
1240                .map(|hit| knowledge_search_result("lib:test", hit))
1241                .collect::<Vec<_>>(),
1242        )
1243        .unwrap();
1244
1245        assert_eq!(value[0]["document"]["selector"], "library://test/leaf.md");
1246        assert_eq!(value[0]["document"]["related"][0]["type"], "references");
1247        assert_eq!(
1248            value[0]["document"]["related"][0]["target"],
1249            "library://test/root.md"
1250        );
1251        assert!(
1252            value[0]["matched"]
1253                .as_array()
1254                .unwrap()
1255                .contains(&json!("title"))
1256        );
1257        assert!(
1258            !value[0]["matched"]
1259                .as_array()
1260                .unwrap()
1261                .contains(&json!("content"))
1262        );
1263        assert!(value[0].get("content").is_none());
1264        assert!(value[0]["document"].get("aliases").is_none());
1265    }
1266
1267    #[test]
1268    fn read_knowledge_doc_result_keeps_full_content_explicit() {
1269        let pack = test_pack();
1270        let doc = pack.read_doc("leaf").expect("leaf doc");
1271        let value = serde_json::to_value(KnowledgeDocReadResult {
1272            document: super::knowledge_document_metadata("lib:test", &doc.manifest),
1273            content: doc.content,
1274        })
1275        .unwrap();
1276
1277        assert_eq!(value["document"]["pack"], "lib:test");
1278        assert_eq!(value["document"]["selector"], "library://test/leaf.md");
1279        assert_eq!(
1280            value["document"]["related"][0]["target"],
1281            "library://test/root.md"
1282        );
1283        assert_eq!(value["content"], "body for Leaf");
1284    }
1285
1286    #[test]
1287    fn library_knowledge_uses_lib_template_namespace() {
1288        let knowledge_ref = KnowledgeRef::library("product-docs").unwrap();
1289        assert_eq!(knowledge_ref.selector(), "lib:product-docs");
1290        assert_eq!(knowledge_ref.prompt_prefix(), "lib.product_docs");
1291    }
1292
1293    #[test]
1294    fn pkg_knowledge_uses_package_template_namespace() {
1295        let knowledge_ref = KnowledgeRef::package("@nenjo/core").unwrap();
1296        assert_eq!(knowledge_ref.selector(), "pkg:nenjo.core");
1297        assert_eq!(knowledge_ref.prompt_prefix(), "pkg.nenjo.core");
1298    }
1299
1300    #[test]
1301    fn pkg_knowledge_document_vars_use_package_relative_paths() {
1302        let knowledge_ref = KnowledgeRef::package("nenjo.core").unwrap();
1303        let doc = KnowledgeDocManifest {
1304            id: "nenjo.resources.agents".into(),
1305            selector: "resources.agents".into(),
1306            source_path: "docs/resources/agents.md".into(),
1307            title: "Agents".into(),
1308            summary: String::new(),
1309            kind: KnowledgeDocKind::new("guide"),
1310            tags: Vec::new(),
1311            related: Vec::new(),
1312            updated_at: String::new(),
1313        };
1314
1315        assert_eq!(
1316            knowledge_document_var_key(&knowledge_ref, &doc),
1317            "pkg.nenjo.core.resources.agents"
1318        );
1319    }
1320}