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("workspace:") {
323        format!("lib.{}", normalize_var_segment(slug))
324    } else if selector == "workspace" {
325        "lib".to_string()
326    } else {
327        selector.replace(':', ".").replace('-', "_")
328    }
329}
330
331pub fn knowledge_pack_summary(selector: &str, pack: &dyn KnowledgePack) -> String {
332    let manifest = pack.manifest();
333    let mut source_name = selector.splitn(2, ':');
334    let source = source_name.next().unwrap_or(selector);
335    let name = source_name.next().unwrap_or(manifest.pack_id());
336    let ctx = KnowledgePackSummaryContext {
337        source,
338        name,
339        root: manifest.root_uri(),
340        usage: "Use the knowledge tools to search, inspect metadata, expand graph neighbors, and read documents from this pack when relevant.",
341        docs: manifest
342            .docs()
343            .iter()
344            .map(|doc| KnowledgeDocumentSummaryContext {
345                path: doc.virtual_path.as_str(),
346                id: doc.id.as_str(),
347                kind: doc.kind.as_str(),
348                title: doc.title.as_str(),
349                summary: doc.summary.as_str(),
350            })
351            .collect(),
352    };
353
354    nenjo_xml::to_xml_pretty(&ctx, 2)
355}
356
357#[derive(Debug, Serialize)]
358#[serde(rename = "knowledge_pack")]
359struct KnowledgePackSummaryContext<'a> {
360    #[serde(rename = "@source")]
361    source: &'a str,
362    #[serde(rename = "@name")]
363    name: &'a str,
364    #[serde(rename = "@root")]
365    root: &'a str,
366    usage: &'a str,
367    #[serde(rename = "doc")]
368    docs: Vec<KnowledgeDocumentSummaryContext<'a>>,
369}
370
371#[derive(Debug, Serialize)]
372#[serde(rename = "doc")]
373struct KnowledgeDocumentSummaryContext<'a> {
374    #[serde(rename = "@path")]
375    path: &'a str,
376    #[serde(rename = "@id")]
377    id: &'a str,
378    #[serde(rename = "@kind")]
379    kind: &'a str,
380    title: &'a str,
381    summary: &'a str,
382}
383
384pub fn knowledge_document_var_key(pack_prefix: &str, doc: &KnowledgeDocManifest) -> String {
385    let relative = pack_relative_path(pack_prefix, doc)
386        .unwrap_or(doc.virtual_path.as_str())
387        .trim_matches('/');
388    let path = relative
389        .strip_suffix(".md")
390        .unwrap_or(relative)
391        .split('/')
392        .filter(|segment| !segment.is_empty())
393        .map(normalize_var_segment)
394        .filter(|segment| !segment.is_empty())
395        .collect::<Vec<_>>()
396        .join(".");
397    if path.is_empty() {
398        pack_prefix.to_string()
399    } else {
400        format!("{pack_prefix}.{path}")
401    }
402}
403
404fn knowledge_document_alias_var_keys(pack_prefix: &str, doc: &KnowledgeDocManifest) -> Vec<String> {
405    let mut keys = Vec::new();
406    let Some(relative) = pack_relative_path(pack_prefix, doc) else {
407        return keys;
408    };
409    let Some((parent, _leaf)) = relative
410        .strip_suffix(".md")
411        .unwrap_or(relative)
412        .rsplit_once('/')
413    else {
414        return keys;
415    };
416    let parent = parent
417        .split('/')
418        .filter(|segment| !segment.is_empty())
419        .map(normalize_var_segment)
420        .filter(|segment| !segment.is_empty())
421        .collect::<Vec<_>>()
422        .join(".");
423
424    if let Some(stripped) = doc.id.strip_prefix("nenjo.") {
425        let id_segments = stripped
426            .split('.')
427            .map(normalize_var_segment)
428            .filter(|segment| !segment.is_empty())
429            .collect::<Vec<_>>();
430        if id_segments.len() >= 2
431            && id_segments
432                .first()
433                .is_some_and(|segment| segment == &parent)
434        {
435            let basename = id_segments[1..].join("_");
436            keys.push(format!("{pack_prefix}.{parent}.nenjo_{basename}"));
437        }
438    }
439
440    keys
441}
442
443fn pack_relative_path<'a>(pack_prefix: &str, doc: &'a KnowledgeDocManifest) -> Option<&'a str> {
444    match pack_prefix {
445        "builtin.nenjo" => doc.virtual_path.strip_prefix("builtin://nenjo/"),
446        "lib" => doc
447            .virtual_path
448            .strip_prefix("library://")
449            .and_then(|rest| rest.split_once('/').map(|(_, path)| path)),
450        _ if pack_prefix.starts_with("lib.") => {
451            let slug = pack_prefix.trim_start_matches("lib.");
452            let prefix = format!("library://{slug}/");
453            doc.virtual_path.strip_prefix(&prefix)
454        }
455        _ => None,
456    }
457}
458
459fn normalize_var_segment(segment: &str) -> String {
460    let mut normalized = String::new();
461    let mut last_was_underscore = false;
462    for ch in segment.chars() {
463        let ch = ch.to_ascii_lowercase();
464        if ch.is_ascii_alphanumeric() {
465            normalized.push(ch);
466            last_was_underscore = false;
467        } else if !last_was_underscore {
468            normalized.push('_');
469            last_was_underscore = true;
470        }
471    }
472    normalized.trim_matches('_').to_string()
473}
474
475#[derive(Debug, Serialize)]
476#[serde(rename = "knowledge_doc")]
477struct KnowledgeDocMetadataContext<'a> {
478    #[serde(rename = "@path")]
479    path: &'a str,
480    #[serde(rename = "@title")]
481    title: &'a str,
482    #[serde(rename = "@kind")]
483    kind: KnowledgeDocKind,
484    #[serde(rename = "@authority")]
485    authority: KnowledgeDocAuthority,
486    #[serde(rename = "@status")]
487    status: KnowledgeDocStatus,
488    summary: &'a str,
489    #[serde(skip_serializing_if = "Option::is_none")]
490    description: Option<&'a str>,
491    #[serde(skip_serializing_if = "Vec::is_empty", default)]
492    tags: Vec<&'a str>,
493    #[serde(skip_serializing_if = "Vec::is_empty", default)]
494    aliases: Vec<&'a str>,
495    #[serde(skip_serializing_if = "Vec::is_empty", default)]
496    keywords: Vec<&'a str>,
497}
498
499fn doc_metadata(doc: &KnowledgeDocManifest) -> String {
500    let path = prompt_doc_path(doc);
501    let ctx = KnowledgeDocMetadataContext {
502        path: &path,
503        title: &doc.title,
504        summary: &doc.summary,
505        description: doc.description.as_deref(),
506        kind: doc.kind,
507        authority: doc.authority,
508        status: doc.status,
509        tags: doc.tags.iter().map(String::as_str).collect(),
510        aliases: doc.aliases.iter().map(String::as_str).collect(),
511        keywords: doc.keywords.iter().map(String::as_str).collect(),
512    };
513    nenjo_xml::to_xml_pretty(&ctx, 2)
514}
515
516fn prompt_doc_path(doc: &KnowledgeDocManifest) -> String {
517    if doc.virtual_path.starts_with("library://") {
518        doc.virtual_path
519            .splitn(4, '/')
520            .nth(3)
521            .unwrap_or(&doc.virtual_path)
522            .to_string()
523    } else {
524        doc.virtual_path.clone()
525    }
526}
527
528fn pack_schema() -> serde_json::Value {
529    json!({
530        "type": "string",
531        "description": "Knowledge pack selector such as builtin:nenjo, workspace:<pack_slug>, or remote:<pack_id>."
532    })
533}
534
535fn knowledge_filter_schema(
536    extra_properties: Option<serde_json::Value>,
537    required: &[&str],
538) -> serde_json::Value {
539    let mut properties = json!({
540        "pack": pack_schema(),
541        "tags": {
542            "type": "array",
543            "items": { "type": "string" },
544            "description": "Optional tags that all returned docs must have"
545        },
546        "kind": {
547            "type": "string",
548            "description": "Optional kind filter such as guide or reference"
549        },
550        "authority": {
551            "type": "string",
552            "description": "Optional authority filter such as canonical, reference, or advisory"
553        },
554        "status": {
555            "type": "string",
556            "description": "Optional status filter such as stable, draft, or deprecated"
557        },
558        "path_prefix": {
559            "type": "string",
560            "description": "Optional virtual or pack-relative path prefix"
561        },
562        "related_to": {
563            "type": "string",
564            "description": "Optional path of a document this result must be related to"
565        },
566        "edge_type": {
567            "type": "string",
568            "description": "Optional relationship type used with related_to or neighbors"
569        }
570    });
571
572    if let Some(extra) = extra_properties
573        && let Some(map) = properties.as_object_mut()
574        && let Some(extra_map) = extra.as_object()
575    {
576        for (key, value) in extra_map {
577            map.insert(key.clone(), value.clone());
578        }
579    }
580
581    json!({
582        "type": "object",
583        "properties": properties,
584        "required": required,
585        "additionalProperties": false
586    })
587}
588
589fn knowledge_lookup_schema() -> serde_json::Value {
590    json!({
591        "type": "object",
592        "properties": {
593            "pack": pack_schema(),
594            "path": {
595                "type": "string",
596                "description": "Document path, id, alias, or virtual path within the selected pack"
597            }
598        },
599        "required": ["pack", "path"],
600        "additionalProperties": false
601    })
602}
603
604pub fn knowledge_tools() -> Vec<ToolSpec> {
605    vec![
606        ToolSpec {
607            name: "list_knowledge_packs".into(),
608            description: "List locally available knowledge packs. Use this before reading or searching knowledge when you need to discover available sources.".into(),
609            parameters: json!({
610                "type": "object",
611                "properties": {},
612                "additionalProperties": false
613            }),
614            category: ToolCategory::Read,
615        },
616        ToolSpec {
617            name: "list_knowledge_docs".into(),
618            description: "List compact document metadata from one knowledge pack without loading document bodies.".into(),
619            parameters: knowledge_filter_schema(None, &["pack"]),
620            category: ToolCategory::Read,
621        },
622        ToolSpec {
623            name: "read_knowledge_doc".into(),
624            description: "Read one full document body from a knowledge pack by path.".into(),
625            parameters: knowledge_lookup_schema(),
626            category: ToolCategory::Read,
627        },
628        ToolSpec {
629            name: "read_knowledge_doc_manifest".into(),
630            description: "Read one document's metadata from a knowledge pack by path without loading the body.".into(),
631            parameters: knowledge_lookup_schema(),
632            category: ToolCategory::Read,
633        },
634        ToolSpec {
635            name: "search_knowledge".into(),
636            description: "Search a knowledge pack and return matches with body content. Use this when you need to inspect or quote matching text.".into(),
637            parameters: knowledge_filter_schema(
638                Some(json!({
639                    "query": {
640                        "type": "string",
641                        "description": "Search query, path, title, tag, summary, or body text"
642                    }
643                })),
644                &["pack", "query"],
645            ),
646            category: ToolCategory::Read,
647        },
648        ToolSpec {
649            name: "search_knowledge_paths".into(),
650            description: "Search a knowledge pack using metadata only and return compact results without body content.".into(),
651            parameters: knowledge_filter_schema(
652                Some(json!({
653                    "query": {
654                        "type": "string",
655                        "description": "Search query, path, title, tag, or summary"
656                    }
657                })),
658                &["pack", "query"],
659            ),
660            category: ToolCategory::Read,
661        },
662        ToolSpec {
663            name: "list_knowledge_tree".into(),
664            description: "List the document tree for a knowledge pack, optionally under a prefix.".into(),
665            parameters: json!({
666                "type": "object",
667                "properties": {
668                    "pack": pack_schema(),
669                    "prefix": {
670                        "type": "string",
671                        "description": "Optional virtual or pack-relative path prefix"
672                    }
673                },
674                "required": ["pack"],
675                "additionalProperties": false
676            }),
677            category: ToolCategory::Read,
678        },
679        ToolSpec {
680            name: "list_knowledge_neighbors".into(),
681            description: "List graph neighbors for one document in a knowledge pack.".into(),
682            parameters: json!({
683                "type": "object",
684                "properties": {
685                    "pack": pack_schema(),
686                    "path": {
687                        "type": "string",
688                        "description": "Document path, id, alias, or virtual path within the selected pack"
689                    },
690                    "edge_type": {
691                        "type": "string",
692                        "description": "Optional relationship type filter such as references or depends_on"
693                    }
694                },
695                "required": ["pack", "path"],
696                "additionalProperties": false
697            }),
698            category: ToolCategory::Read,
699        },
700    ]
701}
702
703pub fn knowledge_toolbelt(registry: Arc<dyn KnowledgeRegistry>) -> Vec<Arc<dyn Tool>> {
704    knowledge_tools()
705        .into_iter()
706        .map(|spec| Arc::new(KnowledgeTool::new(spec, registry.clone())) as Arc<dyn Tool>)
707        .collect()
708}
709
710struct KnowledgeTool {
711    spec: ToolSpec,
712    registry: Arc<dyn KnowledgeRegistry>,
713}
714
715impl KnowledgeTool {
716    fn new(spec: ToolSpec, registry: Arc<dyn KnowledgeRegistry>) -> Self {
717        Self { spec, registry }
718    }
719}
720
721#[async_trait]
722impl Tool for KnowledgeTool {
723    fn name(&self) -> &str {
724        &self.spec.name
725    }
726
727    fn description(&self) -> &str {
728        &self.spec.description
729    }
730
731    fn parameters_schema(&self) -> serde_json::Value {
732        self.spec.parameters.clone()
733    }
734
735    fn category(&self) -> ToolCategory {
736        self.spec.category
737    }
738
739    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
740        let output = match self.name() {
741            "list_knowledge_packs" => serde_json::to_value(self.registry.list_packs().await?)?,
742            "list_knowledge_docs" => {
743                let args: KnowledgeListArgs = serde_json::from_value(args)?;
744                let pack = self.registry.resolve_pack(&args.pack).await?;
745                let filter = knowledge_filter(args.filter)?;
746                let docs = pack
747                    .list_docs(filter)
748                    .into_iter()
749                    .map(|doc| knowledge_manifest_result(&args.pack, doc))
750                    .collect::<Vec<_>>();
751                serde_json::to_value(docs)?
752            }
753            "read_knowledge_doc" => {
754                let args: KnowledgeReadArgs = serde_json::from_value(args)?;
755                let pack = self.registry.resolve_pack(&args.pack).await?;
756                let doc = pack.read_doc(&args.path).ok_or_else(|| {
757                    anyhow!(
758                        "knowledge document '{}' not found in pack '{}'",
759                        args.path,
760                        args.pack
761                    )
762                })?;
763                serde_json::to_value(KnowledgeDocReadResult {
764                    manifest: knowledge_manifest_result(&args.pack, &doc.manifest),
765                    content: doc.content,
766                })?
767            }
768            "read_knowledge_doc_manifest" => {
769                let args: KnowledgeReadArgs = serde_json::from_value(args)?;
770                let pack = self.registry.resolve_pack(&args.pack).await?;
771                let doc = pack.read_manifest(&args.path).ok_or_else(|| {
772                    anyhow!(
773                        "knowledge document '{}' not found in pack '{}'",
774                        args.path,
775                        args.pack
776                    )
777                })?;
778                serde_json::to_value(knowledge_manifest_result(&args.pack, doc))?
779            }
780            "search_knowledge" => {
781                let args: KnowledgeSearchArgs = serde_json::from_value(args)?;
782                let pack = self.registry.resolve_pack(&args.pack).await?;
783                let filter = knowledge_filter(args.filter)?;
784                let hits = pack
785                    .search_docs(&args.query, filter)
786                    .into_iter()
787                    .map(|hit| knowledge_search_result(&args.pack, hit))
788                    .collect::<Vec<_>>();
789                serde_json::to_value(hits)?
790            }
791            "search_knowledge_paths" => {
792                let args: KnowledgeSearchArgs = serde_json::from_value(args)?;
793                let pack = self.registry.resolve_pack(&args.pack).await?;
794                let filter = knowledge_filter(args.filter)?;
795                let hits = pack
796                    .search_paths(&args.query, filter)
797                    .into_iter()
798                    .map(|hit| knowledge_search_result(&args.pack, hit))
799                    .collect::<Vec<_>>();
800                serde_json::to_value(hits)?
801            }
802            "list_knowledge_tree" => {
803                let args: KnowledgeTreeArgs = serde_json::from_value(args)?;
804                let pack = self.registry.resolve_pack(&args.pack).await?;
805                serde_json::to_value(pack.list_tree(args.prefix.as_deref()))?
806            }
807            "list_knowledge_neighbors" => {
808                let args: KnowledgeNeighborArgs = serde_json::from_value(args)?;
809                let pack = self.registry.resolve_pack(&args.pack).await?;
810                let edge_type = parse_knowledge_enum(args.edge_type)?;
811                serde_json::to_value(pack.neighbors(&args.path, edge_type))?
812            }
813            name => return Err(anyhow!("unknown knowledge tool '{name}'")),
814        };
815
816        Ok(ToolResult {
817            success: true,
818            output: serde_json::to_string_pretty(&output)?,
819            error: None,
820        })
821    }
822}
823
824#[cfg(test)]
825mod tests {
826    use super::knowledge_pack_var_prefix;
827
828    #[test]
829    fn workspace_knowledge_uses_lib_template_namespace() {
830        assert_eq!(
831            knowledge_pack_var_prefix("workspace:Product Docs"),
832            "lib.product_docs"
833        );
834        assert_eq!(knowledge_pack_var_prefix("workspace"), "lib");
835    }
836}