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::sync::Arc;
9
10use anyhow::{Context, Result, anyhow};
11use async_trait::async_trait;
12use nenjo_tool_api::{Tool, ToolCategory, ToolResult, ToolSpec};
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15
16use crate::{
17    KnowledgeDocAuthority, KnowledgeDocFilter, KnowledgeDocKind, KnowledgeDocManifest,
18    KnowledgeDocSearchHit, KnowledgeDocStatus, KnowledgePack, KnowledgePackManifest,
19};
20
21#[async_trait]
22pub trait KnowledgeRegistry: Send + Sync {
23    async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>>;
24    async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>>;
25}
26
27#[derive(Clone)]
28pub struct KnowledgePackEntry {
29    selector: String,
30    pack: Arc<dyn KnowledgePack>,
31}
32
33impl KnowledgePackEntry {
34    pub fn new(selector: impl Into<String>, pack: impl KnowledgePack + 'static) -> Self {
35        Self {
36            selector: selector.into(),
37            pack: Arc::new(pack),
38        }
39    }
40
41    pub fn selector(&self) -> &str {
42        &self.selector
43    }
44
45    pub fn pack(&self) -> &Arc<dyn KnowledgePack> {
46        &self.pack
47    }
48
49    fn into_parts(self) -> (String, Arc<dyn KnowledgePack>) {
50        (self.selector, self.pack)
51    }
52}
53
54impl<P> From<(&str, P)> for KnowledgePackEntry
55where
56    P: KnowledgePack + 'static,
57{
58    fn from((selector, pack): (&str, P)) -> Self {
59        Self::new(selector, pack)
60    }
61}
62
63impl<P> From<(String, P)> for KnowledgePackEntry
64where
65    P: KnowledgePack + 'static,
66{
67    fn from((selector, pack): (String, P)) -> Self {
68        Self::new(selector, pack)
69    }
70}
71
72#[derive(Clone, Default)]
73pub struct StaticKnowledgeRegistry {
74    packs: Arc<HashMap<String, Arc<dyn KnowledgePack>>>,
75}
76
77impl StaticKnowledgeRegistry {
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    pub fn with_pack(mut self, selector: impl Into<String>, pack: Arc<dyn KnowledgePack>) -> Self {
83        Arc::make_mut(&mut self.packs).insert(selector.into(), pack);
84        self
85    }
86
87    pub fn with_entry(self, entry: KnowledgePackEntry) -> Self {
88        let (selector, pack) = entry.into_parts();
89        self.with_pack(selector, pack)
90    }
91
92    pub fn with_entries(mut self, entries: impl IntoIterator<Item = KnowledgePackEntry>) -> Self {
93        for entry in entries {
94            self = self.with_entry(entry);
95        }
96        self
97    }
98
99    pub fn is_empty(&self) -> bool {
100        self.packs.is_empty()
101    }
102}
103
104#[async_trait]
105impl KnowledgeRegistry for StaticKnowledgeRegistry {
106    async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>> {
107        let mut packs = self
108            .packs
109            .iter()
110            .map(|(selector, pack)| KnowledgePackSummary::new(selector, pack.manifest()))
111            .collect::<Vec<_>>();
112        packs.sort_by(|a, b| a.pack.cmp(&b.pack));
113        Ok(packs)
114    }
115
116    async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>> {
117        self.packs
118            .get(selector)
119            .cloned()
120            .ok_or_else(|| anyhow!("unknown knowledge pack '{selector}'"))
121    }
122}
123
124#[derive(Debug, Clone, Serialize)]
125pub struct KnowledgePackSummary {
126    pub pack: String,
127    pub pack_id: String,
128    pub pack_version: String,
129    pub root_uri: String,
130    pub document_count: usize,
131}
132
133impl KnowledgePackSummary {
134    pub fn new(pack: impl Into<String>, manifest: &dyn KnowledgePackManifest) -> Self {
135        Self {
136            pack: pack.into(),
137            pack_id: manifest.pack_id().to_string(),
138            pack_version: manifest.pack_version().to_string(),
139            root_uri: manifest.root_uri().to_string(),
140            document_count: manifest.docs().len(),
141        }
142    }
143}
144
145#[derive(Debug, Clone, Deserialize)]
146pub struct KnowledgeListArgs {
147    pub pack: String,
148    #[serde(flatten)]
149    pub filter: KnowledgeFilterArgs,
150}
151
152#[derive(Debug, Clone, Deserialize)]
153pub struct KnowledgeReadArgs {
154    pub pack: String,
155    pub path: String,
156}
157
158#[derive(Debug, Clone, Deserialize)]
159pub struct KnowledgeSearchArgs {
160    pub pack: String,
161    pub query: String,
162    #[serde(flatten)]
163    pub filter: KnowledgeFilterArgs,
164}
165
166#[derive(Debug, Clone, Deserialize)]
167pub struct KnowledgeTreeArgs {
168    pub pack: String,
169    pub prefix: Option<String>,
170}
171
172#[derive(Debug, Clone, Deserialize)]
173pub struct KnowledgeNeighborArgs {
174    pub pack: String,
175    pub path: String,
176    pub edge_type: Option<String>,
177}
178
179#[derive(Debug, Clone, Default, Deserialize)]
180pub struct KnowledgeFilterArgs {
181    #[serde(default)]
182    pub tags: Vec<String>,
183    pub kind: Option<String>,
184    pub authority: Option<String>,
185    pub status: Option<String>,
186    pub path_prefix: Option<String>,
187    pub related_to: Option<String>,
188    pub edge_type: Option<String>,
189}
190
191#[derive(Debug, Clone, Serialize)]
192pub struct KnowledgeDocManifestResult {
193    pub id: String,
194    pub pack: String,
195    pub virtual_path: String,
196    pub source_path: String,
197    pub title: String,
198    pub summary: String,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub description: Option<String>,
201    pub kind: String,
202    pub authority: String,
203    pub status: String,
204    pub tags: Vec<String>,
205    pub aliases: Vec<String>,
206    pub keywords: Vec<String>,
207}
208
209#[derive(Debug, Clone, Serialize)]
210pub struct KnowledgeDocReadResult {
211    pub manifest: KnowledgeDocManifestResult,
212    pub content: String,
213}
214
215#[derive(Debug, Clone, Serialize)]
216pub struct KnowledgeDocSearchResult {
217    pub id: String,
218    pub pack: String,
219    pub virtual_path: String,
220    pub title: String,
221    pub summary: String,
222    pub kind: String,
223    pub authority: String,
224    pub tags: Vec<String>,
225    pub score: usize,
226    pub matched: Vec<String>,
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub content: Option<String>,
229}
230
231pub fn knowledge_filter(filter: KnowledgeFilterArgs) -> Result<KnowledgeDocFilter> {
232    Ok(KnowledgeDocFilter {
233        tags: filter.tags,
234        kind: parse_knowledge_enum(filter.kind)?,
235        authority: parse_knowledge_enum(filter.authority)?,
236        status: parse_knowledge_enum(filter.status)?,
237        path_prefix: filter.path_prefix,
238        related_to: filter.related_to,
239        edge_type: parse_knowledge_enum(filter.edge_type)?,
240    })
241}
242
243pub fn parse_knowledge_enum<T>(value: Option<String>) -> Result<Option<T>>
244where
245    T: serde::de::DeserializeOwned,
246{
247    value
248        .map(|value| {
249            serde_json::from_value(serde_json::Value::String(value.to_lowercase()))
250                .with_context(|| "invalid knowledge filter value")
251        })
252        .transpose()
253}
254
255pub fn knowledge_manifest_result(
256    pack: &str,
257    doc: &KnowledgeDocManifest,
258) -> KnowledgeDocManifestResult {
259    KnowledgeDocManifestResult {
260        id: doc.id.clone(),
261        pack: pack.to_string(),
262        virtual_path: doc.virtual_path.clone(),
263        source_path: doc.source_path.clone(),
264        title: doc.title.clone(),
265        summary: doc.summary.clone(),
266        description: doc.description.clone(),
267        kind: doc.kind.as_str().to_string(),
268        authority: doc.authority.as_str().to_string(),
269        status: doc.status.as_str().to_string(),
270        tags: doc.tags.clone(),
271        aliases: doc.aliases.clone(),
272        keywords: doc.keywords.clone(),
273    }
274}
275
276pub fn knowledge_search_result(pack: &str, hit: KnowledgeDocSearchHit) -> KnowledgeDocSearchResult {
277    KnowledgeDocSearchResult {
278        id: hit.id,
279        pack: pack.to_string(),
280        virtual_path: hit.virtual_path,
281        title: hit.title,
282        summary: hit.summary,
283        kind: hit.kind.as_str().to_string(),
284        authority: hit.authority.as_str().to_string(),
285        tags: hit.tags,
286        score: hit.score,
287        matched: hit.matched,
288        content: hit.content,
289    }
290}
291
292pub fn knowledge_document_metadata_vars(
293    pack_prefix: &str,
294    pack: &dyn KnowledgePack,
295) -> HashMap<String, String> {
296    let mut vars = HashMap::new();
297    for doc in pack.manifest().docs() {
298        let metadata = doc_metadata(doc);
299        vars.insert(
300            knowledge_document_var_key(pack_prefix, doc),
301            metadata.clone(),
302        );
303        for key in knowledge_document_alias_var_keys(pack_prefix, doc) {
304            vars.entry(key).or_insert_with(|| metadata.clone());
305        }
306    }
307    vars
308}
309
310pub fn knowledge_pack_prompt_vars(
311    selector: &str,
312    pack: &dyn KnowledgePack,
313) -> HashMap<String, String> {
314    let prefix = knowledge_pack_var_prefix(selector);
315    let mut vars = HashMap::new();
316    vars.insert(prefix.clone(), knowledge_pack_summary(selector, pack));
317    vars.extend(knowledge_document_metadata_vars(&prefix, pack));
318    vars
319}
320
321pub fn knowledge_pack_var_prefix(selector: &str) -> String {
322    if let Some(slug) = selector.strip_prefix("lib:") {
323        format!("lib.{}", normalize_var_segment(slug))
324    } else if selector == "lib" {
325        "lib".to_string()
326    } else if let Some(git_selector) = selector.strip_prefix("git://") {
327        let segments = git_selector
328            .split('/')
329            .map(normalize_var_segment)
330            .filter(|segment| !segment.is_empty())
331            .collect::<Vec<_>>();
332        if segments.is_empty() {
333            "git".to_string()
334        } else {
335            format!("git.{}", segments.join("."))
336        }
337    } else {
338        selector.replace(':', ".").replace('-', "_")
339    }
340}
341
342pub fn knowledge_pack_summary(selector: &str, pack: &dyn KnowledgePack) -> String {
343    let manifest = pack.manifest();
344    let mut source_name = selector.splitn(2, ':');
345    let source = source_name.next().unwrap_or(selector);
346    let name = source_name.next().unwrap_or(manifest.pack_id());
347    let ctx = KnowledgePackSummaryContext {
348        source,
349        name,
350        root: manifest.root_uri(),
351        usage: "Use the knowledge tools to search, inspect metadata, expand graph neighbors, and read documents from this pack when relevant.",
352        docs: manifest
353            .docs()
354            .iter()
355            .map(|doc| KnowledgeDocumentSummaryContext {
356                path: doc.virtual_path.as_str(),
357                id: doc.id.as_str(),
358                kind: doc.kind.as_str(),
359                title: doc.title.as_str(),
360                summary: doc.summary.as_str(),
361            })
362            .collect(),
363    };
364
365    nenjo_xml::to_xml_pretty(&ctx, 2)
366}
367
368#[derive(Debug, Serialize)]
369#[serde(rename = "knowledge_pack")]
370struct KnowledgePackSummaryContext<'a> {
371    #[serde(rename = "@source")]
372    source: &'a str,
373    #[serde(rename = "@name")]
374    name: &'a str,
375    #[serde(rename = "@root")]
376    root: &'a str,
377    usage: &'a str,
378    #[serde(rename = "doc")]
379    docs: Vec<KnowledgeDocumentSummaryContext<'a>>,
380}
381
382#[derive(Debug, Serialize)]
383#[serde(rename = "doc")]
384struct KnowledgeDocumentSummaryContext<'a> {
385    #[serde(rename = "@path")]
386    path: &'a str,
387    #[serde(rename = "@id")]
388    id: &'a str,
389    #[serde(rename = "@kind")]
390    kind: &'a str,
391    title: &'a str,
392    summary: &'a str,
393}
394
395pub fn knowledge_document_var_key(pack_prefix: &str, doc: &KnowledgeDocManifest) -> String {
396    let relative = pack_relative_path(pack_prefix, doc)
397        .unwrap_or(doc.virtual_path.as_str())
398        .trim_matches('/');
399    let path = relative
400        .strip_suffix(".md")
401        .unwrap_or(relative)
402        .split('/')
403        .filter(|segment| !segment.is_empty())
404        .map(normalize_var_segment)
405        .filter(|segment| !segment.is_empty())
406        .collect::<Vec<_>>()
407        .join(".");
408    if path.is_empty() {
409        pack_prefix.to_string()
410    } else {
411        format!("{pack_prefix}.{path}")
412    }
413}
414
415fn knowledge_document_alias_var_keys(pack_prefix: &str, doc: &KnowledgeDocManifest) -> Vec<String> {
416    let mut keys = Vec::new();
417    let Some(relative) = pack_relative_path(pack_prefix, doc) else {
418        return keys;
419    };
420    let Some((parent, _leaf)) = relative
421        .strip_suffix(".md")
422        .unwrap_or(relative)
423        .rsplit_once('/')
424    else {
425        return keys;
426    };
427    let parent = parent
428        .split('/')
429        .filter(|segment| !segment.is_empty())
430        .map(normalize_var_segment)
431        .filter(|segment| !segment.is_empty())
432        .collect::<Vec<_>>()
433        .join(".");
434
435    if let Some(stripped) = doc.id.strip_prefix("nenjo.") {
436        let id_segments = stripped
437            .split('.')
438            .map(normalize_var_segment)
439            .filter(|segment| !segment.is_empty())
440            .collect::<Vec<_>>();
441        if id_segments.len() >= 2
442            && id_segments
443                .first()
444                .is_some_and(|segment| segment == &parent)
445        {
446            let basename = id_segments[1..].join("_");
447            keys.push(format!("{pack_prefix}.{parent}.nenjo_{basename}"));
448        }
449    }
450
451    keys
452}
453
454fn pack_relative_path<'a>(pack_prefix: &str, doc: &'a KnowledgeDocManifest) -> Option<&'a str> {
455    match pack_prefix {
456        "lib" => doc
457            .virtual_path
458            .strip_prefix("library://")
459            .and_then(|rest| rest.split_once('/').map(|(_, path)| path)),
460        _ if pack_prefix.starts_with("lib.") => {
461            let slug = pack_prefix.trim_start_matches("lib.");
462            let prefix = format!("library://{slug}/");
463            doc.virtual_path.strip_prefix(&prefix)
464        }
465        _ if pack_prefix.starts_with("git.") => {
466            let mut path = doc.virtual_path.strip_prefix("git://")?;
467            for expected in pack_prefix.trim_start_matches("git.").split('.') {
468                let (segment, rest) = path.split_once('/').unwrap_or((path, ""));
469                if normalize_var_segment(segment) != expected {
470                    return None;
471                }
472                path = rest;
473            }
474            Some(path)
475        }
476        _ => None,
477    }
478}
479
480fn normalize_var_segment(segment: &str) -> String {
481    let mut normalized = String::new();
482    let mut last_was_underscore = false;
483    for ch in segment.chars() {
484        let ch = ch.to_ascii_lowercase();
485        if ch.is_ascii_alphanumeric() {
486            normalized.push(ch);
487            last_was_underscore = false;
488        } else if !last_was_underscore {
489            normalized.push('_');
490            last_was_underscore = true;
491        }
492    }
493    normalized.trim_matches('_').to_string()
494}
495
496#[derive(Debug, Serialize)]
497#[serde(rename = "knowledge_doc")]
498struct KnowledgeDocMetadataContext<'a> {
499    #[serde(rename = "@path")]
500    path: &'a str,
501    #[serde(rename = "@title")]
502    title: &'a str,
503    #[serde(rename = "@kind")]
504    kind: KnowledgeDocKind,
505    #[serde(rename = "@authority")]
506    authority: KnowledgeDocAuthority,
507    #[serde(rename = "@status")]
508    status: KnowledgeDocStatus,
509    summary: &'a str,
510    #[serde(skip_serializing_if = "Option::is_none")]
511    description: Option<&'a str>,
512    #[serde(skip_serializing_if = "Vec::is_empty", default)]
513    tags: Vec<&'a str>,
514    #[serde(skip_serializing_if = "Vec::is_empty", default)]
515    aliases: Vec<&'a str>,
516    #[serde(skip_serializing_if = "Vec::is_empty", default)]
517    keywords: Vec<&'a str>,
518}
519
520fn doc_metadata(doc: &KnowledgeDocManifest) -> String {
521    let path = prompt_doc_path(doc);
522    let ctx = KnowledgeDocMetadataContext {
523        path: &path,
524        title: &doc.title,
525        summary: &doc.summary,
526        description: doc.description.as_deref(),
527        kind: doc.kind,
528        authority: doc.authority,
529        status: doc.status,
530        tags: doc.tags.iter().map(String::as_str).collect(),
531        aliases: doc.aliases.iter().map(String::as_str).collect(),
532        keywords: doc.keywords.iter().map(String::as_str).collect(),
533    };
534    nenjo_xml::to_xml_pretty(&ctx, 2)
535}
536
537fn prompt_doc_path(doc: &KnowledgeDocManifest) -> String {
538    if doc.virtual_path.starts_with("library://") {
539        doc.virtual_path
540            .splitn(4, '/')
541            .nth(3)
542            .unwrap_or(&doc.virtual_path)
543            .to_string()
544    } else {
545        doc.virtual_path.clone()
546    }
547}
548
549fn pack_schema() -> serde_json::Value {
550    json!({
551        "type": "string",
552        "description": "Knowledge pack selector such as lib:<pack_slug> or git://owner/repo/package."
553    })
554}
555
556fn knowledge_filter_schema(
557    extra_properties: Option<serde_json::Value>,
558    required: &[&str],
559) -> serde_json::Value {
560    let mut properties = json!({
561        "pack": pack_schema(),
562        "tags": {
563            "type": "array",
564            "items": { "type": "string" },
565            "description": "Optional tags that all returned docs must have"
566        },
567        "kind": {
568            "type": "string",
569            "description": "Optional kind filter such as guide or reference"
570        },
571        "authority": {
572            "type": "string",
573            "description": "Optional authority filter such as canonical, reference, or advisory"
574        },
575        "status": {
576            "type": "string",
577            "description": "Optional status filter such as stable, draft, or deprecated"
578        },
579        "path_prefix": {
580            "type": "string",
581            "description": "Optional virtual or pack-relative path prefix"
582        },
583        "related_to": {
584            "type": "string",
585            "description": "Optional path of a document this result must be related to"
586        },
587        "edge_type": {
588            "type": "string",
589            "description": "Optional relationship type used with related_to or neighbors"
590        }
591    });
592
593    if let Some(extra) = extra_properties
594        && let Some(map) = properties.as_object_mut()
595        && let Some(extra_map) = extra.as_object()
596    {
597        for (key, value) in extra_map {
598            map.insert(key.clone(), value.clone());
599        }
600    }
601
602    json!({
603        "type": "object",
604        "properties": properties,
605        "required": required,
606        "additionalProperties": false
607    })
608}
609
610fn knowledge_lookup_schema() -> serde_json::Value {
611    json!({
612        "type": "object",
613        "properties": {
614            "pack": pack_schema(),
615            "path": {
616                "type": "string",
617                "description": "Document path, id, alias, or virtual path within the selected pack"
618            }
619        },
620        "required": ["pack", "path"],
621        "additionalProperties": false
622    })
623}
624
625pub fn knowledge_tools() -> Vec<ToolSpec> {
626    vec![
627        ToolSpec {
628            name: "list_knowledge_packs".into(),
629            description: "List locally available knowledge packs. Use this before reading or searching knowledge when you need to discover available sources.".into(),
630            parameters: json!({
631                "type": "object",
632                "properties": {},
633                "additionalProperties": false
634            }),
635            category: ToolCategory::Read,
636        },
637        ToolSpec {
638            name: "list_knowledge_docs".into(),
639            description: "List compact document metadata from one knowledge pack without loading document bodies.".into(),
640            parameters: knowledge_filter_schema(None, &["pack"]),
641            category: ToolCategory::Read,
642        },
643        ToolSpec {
644            name: "read_knowledge_doc".into(),
645            description: "Read one full document body from a knowledge pack by path.".into(),
646            parameters: knowledge_lookup_schema(),
647            category: ToolCategory::Read,
648        },
649        ToolSpec {
650            name: "read_knowledge_doc_manifest".into(),
651            description: "Read one document's metadata from a knowledge pack by path without loading the body.".into(),
652            parameters: knowledge_lookup_schema(),
653            category: ToolCategory::Read,
654        },
655        ToolSpec {
656            name: "search_knowledge".into(),
657            description: "Search a knowledge pack and return matches with body content. Use this when you need to inspect or quote matching text.".into(),
658            parameters: knowledge_filter_schema(
659                Some(json!({
660                    "query": {
661                        "type": "string",
662                        "description": "Search query, path, title, tag, summary, or body text"
663                    }
664                })),
665                &["pack", "query"],
666            ),
667            category: ToolCategory::Read,
668        },
669        ToolSpec {
670            name: "search_knowledge_paths".into(),
671            description: "Search a knowledge pack using metadata only and return compact results without body content.".into(),
672            parameters: knowledge_filter_schema(
673                Some(json!({
674                    "query": {
675                        "type": "string",
676                        "description": "Search query, path, title, tag, or summary"
677                    }
678                })),
679                &["pack", "query"],
680            ),
681            category: ToolCategory::Read,
682        },
683        ToolSpec {
684            name: "list_knowledge_tree".into(),
685            description: "List the document tree for a knowledge pack, optionally under a prefix.".into(),
686            parameters: json!({
687                "type": "object",
688                "properties": {
689                    "pack": pack_schema(),
690                    "prefix": {
691                        "type": "string",
692                        "description": "Optional virtual or pack-relative path prefix"
693                    }
694                },
695                "required": ["pack"],
696                "additionalProperties": false
697            }),
698            category: ToolCategory::Read,
699        },
700        ToolSpec {
701            name: "list_knowledge_neighbors".into(),
702            description: "List graph neighbors for one document in a knowledge pack.".into(),
703            parameters: json!({
704                "type": "object",
705                "properties": {
706                    "pack": pack_schema(),
707                    "path": {
708                        "type": "string",
709                        "description": "Document path, id, alias, or virtual path within the selected pack"
710                    },
711                    "edge_type": {
712                        "type": "string",
713                        "description": "Optional relationship type filter such as references or depends_on"
714                    }
715                },
716                "required": ["pack", "path"],
717                "additionalProperties": false
718            }),
719            category: ToolCategory::Read,
720        },
721    ]
722}
723
724pub fn knowledge_toolbelt(registry: Arc<dyn KnowledgeRegistry>) -> Vec<Arc<dyn Tool>> {
725    knowledge_tools()
726        .into_iter()
727        .map(|spec| Arc::new(KnowledgeTool::new(spec, registry.clone())) as Arc<dyn Tool>)
728        .collect()
729}
730
731struct KnowledgeTool {
732    spec: ToolSpec,
733    registry: Arc<dyn KnowledgeRegistry>,
734}
735
736impl KnowledgeTool {
737    fn new(spec: ToolSpec, registry: Arc<dyn KnowledgeRegistry>) -> Self {
738        Self { spec, registry }
739    }
740}
741
742#[async_trait]
743impl Tool for KnowledgeTool {
744    fn name(&self) -> &str {
745        &self.spec.name
746    }
747
748    fn description(&self) -> &str {
749        &self.spec.description
750    }
751
752    fn parameters_schema(&self) -> serde_json::Value {
753        self.spec.parameters.clone()
754    }
755
756    fn category(&self) -> ToolCategory {
757        self.spec.category
758    }
759
760    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
761        let output = match self.name() {
762            "list_knowledge_packs" => serde_json::to_value(self.registry.list_packs().await?)?,
763            "list_knowledge_docs" => {
764                let args: KnowledgeListArgs = serde_json::from_value(args)?;
765                let pack = self.registry.resolve_pack(&args.pack).await?;
766                let filter = knowledge_filter(args.filter)?;
767                let docs = pack
768                    .list_docs(filter)
769                    .into_iter()
770                    .map(|doc| knowledge_manifest_result(&args.pack, doc))
771                    .collect::<Vec<_>>();
772                serde_json::to_value(docs)?
773            }
774            "read_knowledge_doc" => {
775                let args: KnowledgeReadArgs = serde_json::from_value(args)?;
776                let pack = self.registry.resolve_pack(&args.pack).await?;
777                let doc = pack.read_doc(&args.path).ok_or_else(|| {
778                    anyhow!(
779                        "knowledge document '{}' not found in pack '{}'",
780                        args.path,
781                        args.pack
782                    )
783                })?;
784                serde_json::to_value(KnowledgeDocReadResult {
785                    manifest: knowledge_manifest_result(&args.pack, &doc.manifest),
786                    content: doc.content,
787                })?
788            }
789            "read_knowledge_doc_manifest" => {
790                let args: KnowledgeReadArgs = serde_json::from_value(args)?;
791                let pack = self.registry.resolve_pack(&args.pack).await?;
792                let doc = pack.read_manifest(&args.path).ok_or_else(|| {
793                    anyhow!(
794                        "knowledge document '{}' not found in pack '{}'",
795                        args.path,
796                        args.pack
797                    )
798                })?;
799                serde_json::to_value(knowledge_manifest_result(&args.pack, doc))?
800            }
801            "search_knowledge" => {
802                let args: KnowledgeSearchArgs = serde_json::from_value(args)?;
803                let pack = self.registry.resolve_pack(&args.pack).await?;
804                let filter = knowledge_filter(args.filter)?;
805                let hits = pack
806                    .search_docs(&args.query, filter)
807                    .into_iter()
808                    .map(|hit| knowledge_search_result(&args.pack, hit))
809                    .collect::<Vec<_>>();
810                serde_json::to_value(hits)?
811            }
812            "search_knowledge_paths" => {
813                let args: KnowledgeSearchArgs = serde_json::from_value(args)?;
814                let pack = self.registry.resolve_pack(&args.pack).await?;
815                let filter = knowledge_filter(args.filter)?;
816                let hits = pack
817                    .search_paths(&args.query, filter)
818                    .into_iter()
819                    .map(|hit| knowledge_search_result(&args.pack, hit))
820                    .collect::<Vec<_>>();
821                serde_json::to_value(hits)?
822            }
823            "list_knowledge_tree" => {
824                let args: KnowledgeTreeArgs = serde_json::from_value(args)?;
825                let pack = self.registry.resolve_pack(&args.pack).await?;
826                serde_json::to_value(pack.list_tree(args.prefix.as_deref()))?
827            }
828            "list_knowledge_neighbors" => {
829                let args: KnowledgeNeighborArgs = serde_json::from_value(args)?;
830                let pack = self.registry.resolve_pack(&args.pack).await?;
831                let edge_type = parse_knowledge_enum(args.edge_type)?;
832                serde_json::to_value(pack.neighbors(&args.path, edge_type))?
833            }
834            name => return Err(anyhow!("unknown knowledge tool '{name}'")),
835        };
836
837        Ok(ToolResult {
838            success: true,
839            output: serde_json::to_string_pretty(&output)?,
840            error: None,
841        })
842    }
843}
844
845#[cfg(test)]
846mod tests {
847    use super::{knowledge_document_var_key, knowledge_pack_var_prefix};
848    use crate::{
849        KnowledgeDocAuthority, KnowledgeDocKind, KnowledgeDocManifest, KnowledgeDocStatus,
850    };
851
852    #[test]
853    fn library_knowledge_uses_lib_template_namespace() {
854        assert_eq!(
855            knowledge_pack_var_prefix("lib:Product Docs"),
856            "lib.product_docs"
857        );
858        assert_eq!(knowledge_pack_var_prefix("lib"), "lib");
859    }
860
861    #[test]
862    fn git_knowledge_uses_owner_qualified_template_namespace() {
863        assert_eq!(
864            knowledge_pack_var_prefix("git://nenjo-ai/packages/nenjo/platform"),
865            "git.nenjo_ai.packages.nenjo.platform"
866        );
867        assert_eq!(
868            knowledge_pack_var_prefix("git://trailofbits/skills-curated/x-research"),
869            "git.trailofbits.skills_curated.x_research"
870        );
871    }
872
873    #[test]
874    fn git_knowledge_document_vars_use_pack_relative_paths() {
875        let doc = KnowledgeDocManifest {
876            id: "nenjo.guide.agents".into(),
877            virtual_path: "git://nenjo-ai/packages/nenjo/platform/guide/agents.md".into(),
878            source_path: "docs/guide/agents.md".into(),
879            title: "Agents".into(),
880            summary: String::new(),
881            description: None,
882            kind: KnowledgeDocKind::Guide,
883            authority: KnowledgeDocAuthority::Canonical,
884            status: KnowledgeDocStatus::Stable,
885            tags: Vec::new(),
886            aliases: Vec::new(),
887            keywords: Vec::new(),
888            related: Vec::new(),
889            size_bytes: 0,
890            updated_at: String::new(),
891        };
892
893        assert_eq!(
894            knowledge_document_var_key("git.nenjo_ai.packages.nenjo.platform", &doc),
895            "git.nenjo_ai.packages.nenjo.platform.guide.agents"
896        );
897    }
898}