use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use nenjo_tool_api::{Tool, ToolCategory, ToolOrigin, ToolResult, ToolSpec};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{
KnowledgeDocFilter, KnowledgeDocManifest, KnowledgeDocNeighbor, KnowledgeDocSearchHit,
KnowledgePack, KnowledgePackManifest,
};
#[async_trait]
pub trait KnowledgeRegistry: Send + Sync {
async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>>;
async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>>;
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct KnowledgeName(String);
impl KnowledgeName {
pub fn parse(value: impl AsRef<str>) -> Result<Self> {
let value = value.as_ref().trim().to_ascii_lowercase();
if value.is_empty() {
return Err(anyhow!("knowledge name cannot be empty"));
}
if value.starts_with(['_', '-']) || value.ends_with(['_', '-']) {
return Err(anyhow!(
"knowledge name cannot start or end with a separator"
));
}
if !value
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-')
{
return Err(anyhow!(
"knowledge name may contain only lowercase letters, numbers, underscores, and hyphens"
));
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn prompt_segment(&self) -> String {
normalize_var_segment(&self.0)
}
}
impl fmt::Display for KnowledgeName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PackageKnowledgeName(Vec<KnowledgeName>);
impl PackageKnowledgeName {
pub fn parse(value: impl AsRef<str>) -> Result<Self> {
let raw = value.as_ref().trim();
let raw = raw.strip_prefix('@').unwrap_or(raw);
let segments = raw
.split(['.', '/'])
.map(KnowledgeName::parse)
.collect::<Result<Vec<_>>>()?;
if segments.is_empty() {
return Err(anyhow!("package knowledge name cannot be empty"));
}
Ok(Self(segments))
}
pub fn prompt_path(&self) -> String {
self.0
.iter()
.map(KnowledgeName::prompt_segment)
.collect::<Vec<_>>()
.join(".")
}
pub fn selector_name(&self) -> String {
self.0
.iter()
.map(KnowledgeName::as_str)
.collect::<Vec<_>>()
.join(".")
}
}
impl fmt::Display for PackageKnowledgeName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.selector_name())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum KnowledgeRef {
Library { pack: KnowledgeName },
Package { package: PackageKnowledgeName },
Local { pack: KnowledgeName },
}
impl KnowledgeRef {
pub fn library(pack: impl AsRef<str>) -> Result<Self> {
Ok(Self::Library {
pack: KnowledgeName::parse(pack)?,
})
}
pub fn package(package: impl AsRef<str>) -> Result<Self> {
Ok(Self::Package {
package: PackageKnowledgeName::parse(package)?,
})
}
pub fn local(pack: impl AsRef<str>) -> Result<Self> {
Ok(Self::Local {
pack: KnowledgeName::parse(pack)?,
})
}
pub fn selector(&self) -> String {
self.to_string()
}
pub fn prompt_prefix(&self) -> String {
match self {
Self::Library { pack } => format!("lib.{}", pack.prompt_segment()),
Self::Package { package } => format!("pkg.{}", package.prompt_path()),
Self::Local { pack } => format!("local.{}", pack.prompt_segment()),
}
}
}
impl fmt::Display for KnowledgeRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Library { pack } => write!(f, "lib:{pack}"),
Self::Package { package } => write!(f, "pkg:{package}"),
Self::Local { pack } => write!(f, "local:{pack}"),
}
}
}
impl FromStr for KnowledgeRef {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let value = value.trim();
if let Some(pack) = value.strip_prefix("lib:") {
return Self::library(pack);
}
if let Some(pack) = value.strip_prefix("local:") {
return Self::local(pack);
}
if let Some(package) = value.strip_prefix("pkg:") {
return Self::package(package);
}
Err(anyhow!(
"invalid knowledge selector '{value}'; expected lib:<pack>, pkg:<package>, or local:<pack>"
))
}
}
#[derive(Clone)]
pub struct KnowledgePackEntry {
knowledge_ref: KnowledgeRef,
pack: Arc<dyn KnowledgePack>,
}
impl KnowledgePackEntry {
pub fn new(knowledge_ref: KnowledgeRef, pack: impl KnowledgePack + 'static) -> Self {
Self {
knowledge_ref,
pack: Arc::new(pack),
}
}
pub fn library(pack_name: impl AsRef<str>, pack: impl KnowledgePack + 'static) -> Result<Self> {
Ok(Self::new(KnowledgeRef::library(pack_name)?, pack))
}
pub fn package(
package_name: impl AsRef<str>,
pack: impl KnowledgePack + 'static,
) -> Result<Self> {
Ok(Self::new(KnowledgeRef::package(package_name)?, pack))
}
pub fn local(pack_name: impl AsRef<str>, pack: impl KnowledgePack + 'static) -> Result<Self> {
Ok(Self::new(KnowledgeRef::local(pack_name)?, pack))
}
pub fn knowledge_ref(&self) -> &KnowledgeRef {
&self.knowledge_ref
}
pub fn selector(&self) -> String {
self.knowledge_ref.selector()
}
pub fn pack(&self) -> &Arc<dyn KnowledgePack> {
&self.pack
}
fn into_parts(self) -> (KnowledgeRef, Arc<dyn KnowledgePack>) {
(self.knowledge_ref, self.pack)
}
}
#[derive(Clone, Default)]
pub struct StaticKnowledgeRegistry {
packs: Arc<HashMap<String, Arc<dyn KnowledgePack>>>,
}
impl StaticKnowledgeRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn with_pack(mut self, selector: impl Into<String>, pack: Arc<dyn KnowledgePack>) -> Self {
Arc::make_mut(&mut self.packs).insert(selector.into(), pack);
self
}
pub fn with_entry(self, entry: KnowledgePackEntry) -> Self {
let (knowledge_ref, pack) = entry.into_parts();
self.with_pack(knowledge_ref.selector(), pack)
}
pub fn with_entries(mut self, entries: impl IntoIterator<Item = KnowledgePackEntry>) -> Self {
for entry in entries {
self = self.with_entry(entry);
}
self
}
pub fn is_empty(&self) -> bool {
self.packs.is_empty()
}
}
#[derive(Clone, Default)]
pub struct CompositeKnowledgeRegistry {
library: StaticKnowledgeRegistry,
package: StaticKnowledgeRegistry,
local: StaticKnowledgeRegistry,
}
impl CompositeKnowledgeRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn with_entry(mut self, entry: KnowledgePackEntry) -> Self {
match entry.knowledge_ref() {
KnowledgeRef::Library { .. } => {
self.library = self.library.with_entry(entry);
}
KnowledgeRef::Package { .. } => {
self.package = self.package.with_entry(entry);
}
KnowledgeRef::Local { .. } => {
self.local = self.local.with_entry(entry);
}
}
self
}
pub fn with_entries(mut self, entries: impl IntoIterator<Item = KnowledgePackEntry>) -> Self {
for entry in entries {
self = self.with_entry(entry);
}
self
}
pub fn is_empty(&self) -> bool {
self.library.is_empty() && self.package.is_empty() && self.local.is_empty()
}
pub fn from_entries(entries: impl IntoIterator<Item = KnowledgePackEntry>) -> Self {
entries
.into_iter()
.fold(Self::new(), |registry, entry| registry.with_entry(entry))
}
fn static_registry_for_selector(&self, selector: &str) -> Result<&StaticKnowledgeRegistry> {
match KnowledgeRef::from_str(selector)? {
KnowledgeRef::Library { .. } => Ok(&self.library),
KnowledgeRef::Package { .. } => Ok(&self.package),
KnowledgeRef::Local { .. } => Ok(&self.local),
}
}
}
#[async_trait]
impl KnowledgeRegistry for CompositeKnowledgeRegistry {
async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>> {
let mut packs = Vec::new();
packs.extend(self.library.list_packs().await?);
packs.extend(self.package.list_packs().await?);
packs.extend(self.local.list_packs().await?);
packs.sort_by(|a, b| a.pack.cmp(&b.pack));
Ok(packs)
}
async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>> {
self.static_registry_for_selector(selector)?
.resolve_pack(selector)
.await
}
}
#[async_trait]
impl KnowledgeRegistry for StaticKnowledgeRegistry {
async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>> {
let mut packs = self
.packs
.iter()
.map(|(selector, pack)| KnowledgePackSummary::new(selector, pack.manifest()))
.collect::<Vec<_>>();
packs.sort_by(|a, b| a.pack.cmp(&b.pack));
Ok(packs)
}
async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>> {
self.packs
.get(selector)
.cloned()
.ok_or_else(|| anyhow!("unknown knowledge pack '{selector}'"))
}
}
#[derive(Debug, Clone, Serialize)]
pub struct KnowledgePackSummary {
pub selector: String,
pub pack: String,
pub pack_id: String,
pub version: String,
pub root_uri: String,
pub document_count: usize,
}
impl KnowledgePackSummary {
pub fn new(pack: impl Into<String>, manifest: &dyn KnowledgePackManifest) -> Self {
let selector = pack.into();
Self {
pack: selector.clone(),
selector,
pack_id: manifest.pack_id().to_string(),
version: manifest.version().to_string(),
root_uri: manifest.root_uri().to_string(),
document_count: manifest.docs().len(),
}
}
pub fn from_parts(
selector: impl Into<String>,
pack_id: impl Into<String>,
version: impl Into<String>,
root_uri: impl Into<String>,
document_count: usize,
) -> Self {
let selector = selector.into();
Self {
pack: selector.clone(),
selector,
pack_id: pack_id.into(),
version: version.into(),
root_uri: root_uri.into(),
document_count,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct KnowledgeReadArgs {
pub pack: String,
pub selector: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KnowledgeSearchArgs {
pub pack: String,
pub query: String,
#[serde(flatten)]
pub filter: KnowledgeFilterArgs,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KnowledgeNeighborArgs {
pub pack: String,
pub selector: String,
pub edge_type: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct KnowledgeFilterArgs {
#[serde(default)]
pub tags: Vec<String>,
pub kind: Option<String>,
pub selector_prefix: Option<String>,
pub related_to: Option<String>,
pub edge_type: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct KnowledgeDocMetadataResult {
pub pack: String,
pub slug: String,
pub selector: String,
pub title: String,
pub summary: String,
pub kind: String,
pub tags: Vec<String>,
pub related: Vec<KnowledgeDocRelatedResult>,
}
#[derive(Debug, Clone, Serialize)]
pub struct KnowledgeDocRelatedResult {
#[serde(rename = "type")]
pub edge_type: String,
pub target: String,
pub target_doc: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct KnowledgeDocReadResult {
pub document: KnowledgeDocMetadataResult,
pub content: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct KnowledgeDocSearchResult {
pub document: KnowledgeDocMetadataResult,
pub score: usize,
pub matched: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct KnowledgeDocNeighborsResult {
pub document: KnowledgeDocMetadataResult,
pub edges: Vec<KnowledgeDocNeighborEdgeResult>,
}
#[derive(Debug, Clone, Serialize)]
pub struct KnowledgeDocNeighborEdgeResult {
#[serde(rename = "type")]
pub edge_type: String,
pub target: KnowledgeDocMetadataResult,
}
pub fn knowledge_filter(filter: KnowledgeFilterArgs) -> Result<KnowledgeDocFilter> {
Ok(KnowledgeDocFilter {
tags: filter.tags,
kind: parse_knowledge_enum(filter.kind)?,
selector_prefix: filter.selector_prefix,
related_to: filter.related_to,
edge_type: parse_knowledge_enum(filter.edge_type)?,
})
}
pub fn parse_knowledge_enum<T>(value: Option<String>) -> Result<Option<T>>
where
T: serde::de::DeserializeOwned,
{
value
.map(|value| {
serde_json::from_value(serde_json::Value::String(value.to_lowercase()))
.with_context(|| "invalid knowledge filter value")
})
.transpose()
}
pub fn knowledge_document_metadata(
pack: impl Into<String>,
doc: &KnowledgeDocManifest,
pack_source: Option<&dyn KnowledgePack>,
) -> KnowledgeDocMetadataResult {
KnowledgeDocMetadataResult {
pack: pack.into(),
slug: doc.id.clone(),
selector: doc.selector.clone(),
title: doc.title.clone(),
summary: doc.summary.clone(),
kind: doc.kind.as_str().to_string(),
tags: doc.tags.clone(),
related: doc
.related
.iter()
.map(|edge| KnowledgeDocRelatedResult {
edge_type: edge.edge_type.as_str().to_string(),
target: edge.target.clone(),
target_doc: pack_source
.and_then(|pack| pack.read_manifest(&edge.target))
.map(|target| target.id.clone())
.unwrap_or_else(|| edge.target.clone()),
})
.collect(),
}
}
pub fn knowledge_search_result(
pack: impl Into<String>,
pack_source: &dyn KnowledgePack,
hit: KnowledgeDocSearchHit,
) -> KnowledgeDocSearchResult {
KnowledgeDocSearchResult {
document: knowledge_document_metadata(pack, &hit.document, Some(pack_source)),
score: hit.score,
matched: hit.matched,
}
}
pub fn knowledge_neighbors_result(
pack: impl Into<String> + Clone,
pack_source: &dyn KnowledgePack,
neighbors: KnowledgeDocNeighbor,
) -> KnowledgeDocNeighborsResult {
KnowledgeDocNeighborsResult {
document: knowledge_document_metadata(pack.clone(), &neighbors.document, Some(pack_source)),
edges: neighbors
.edges
.into_iter()
.map(|edge| KnowledgeDocNeighborEdgeResult {
edge_type: edge.edge_type.as_str().to_string(),
target: knowledge_document_metadata(pack.clone(), &edge.target, Some(pack_source)),
})
.collect(),
}
}
pub fn knowledge_document_metadata_vars(
knowledge_ref: &KnowledgeRef,
pack: &dyn KnowledgePack,
) -> HashMap<String, String> {
let mut vars = HashMap::new();
for doc in pack.manifest().docs() {
let metadata = doc_metadata(knowledge_ref, doc);
vars.insert(
knowledge_document_var_key(knowledge_ref, doc),
metadata.clone(),
);
for key in knowledge_document_alias_var_keys(knowledge_ref, doc) {
vars.entry(key).or_insert_with(|| metadata.clone());
}
}
vars
}
pub fn knowledge_prompt_vars_from_entries(
entries: impl IntoIterator<Item = KnowledgePackEntry>,
) -> HashMap<String, String> {
let mut vars = HashMap::new();
for entry in entries {
vars.extend(knowledge_pack_prompt_vars(
entry.knowledge_ref(),
entry.pack().as_ref(),
));
}
vars
}
pub fn knowledge_pack_prompt_vars(
knowledge_ref: &KnowledgeRef,
pack: &dyn KnowledgePack,
) -> HashMap<String, String> {
let prefix = knowledge_ref.prompt_prefix();
let mut vars = HashMap::new();
vars.insert(prefix, knowledge_pack_summary(knowledge_ref, pack));
vars.extend(knowledge_document_metadata_vars(knowledge_ref, pack));
vars
}
pub fn knowledge_pack_summary(knowledge_ref: &KnowledgeRef, pack: &dyn KnowledgePack) -> String {
let manifest = pack.manifest();
let selector = knowledge_ref.selector();
let namespace = match knowledge_ref {
KnowledgeRef::Library { .. } => "lib",
KnowledgeRef::Package { .. } => "pkg",
KnowledgeRef::Local { .. } => "local",
};
let ctx = KnowledgePackSummaryContext {
selector: selector.as_str(),
namespace,
name: manifest.pack_id(),
root: manifest.root_uri(),
usage: "Use the knowledge tools to search, inspect metadata, expand graph neighbors, and read documents from this pack when relevant.",
docs: manifest
.docs()
.iter()
.map(|doc| KnowledgeDocumentSummaryContext {
selector: doc.selector.as_str(),
id: doc.id.as_str(),
kind: doc.kind.as_str(),
title: doc.title.as_str(),
summary: doc.summary.as_str(),
related: doc
.related
.iter()
.map(|edge| KnowledgeDocumentRelatedSummaryContext {
edge_type: edge.edge_type.as_str(),
target: edge.target.as_str(),
})
.collect(),
})
.collect(),
};
nenjo_xml::to_xml_pretty(&ctx, 2)
}
#[derive(Debug, Serialize)]
#[serde(rename = "knowledge_pack")]
struct KnowledgePackSummaryContext<'a> {
#[serde(rename = "@selector")]
selector: &'a str,
#[serde(rename = "@namespace")]
namespace: &'a str,
#[serde(rename = "@name")]
name: &'a str,
#[serde(rename = "@root")]
root: &'a str,
usage: &'a str,
#[serde(rename = "doc")]
docs: Vec<KnowledgeDocumentSummaryContext<'a>>,
}
#[derive(Debug, Serialize)]
#[serde(rename = "doc")]
struct KnowledgeDocumentSummaryContext<'a> {
#[serde(rename = "@selector")]
selector: &'a str,
#[serde(rename = "@id")]
id: &'a str,
#[serde(rename = "@kind")]
kind: &'a str,
title: &'a str,
summary: &'a str,
#[serde(rename = "related", skip_serializing_if = "Vec::is_empty", default)]
related: Vec<KnowledgeDocumentRelatedSummaryContext<'a>>,
}
#[derive(Debug, Serialize)]
#[serde(rename = "related")]
struct KnowledgeDocumentRelatedSummaryContext<'a> {
#[serde(rename = "@type")]
edge_type: &'a str,
#[serde(rename = "@target")]
target: &'a str,
}
pub fn knowledge_document_var_key(
knowledge_ref: &KnowledgeRef,
doc: &KnowledgeDocManifest,
) -> String {
let pack_prefix = knowledge_ref.prompt_prefix();
let selector = prompt_doc_selector(doc);
let path = selector
.strip_suffix(".md")
.unwrap_or(selector.as_str())
.split(['.', '/'])
.filter(|segment| !segment.is_empty())
.map(normalize_var_segment)
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join(".");
if path.is_empty() {
pack_prefix
} else {
format!("{pack_prefix}.{path}")
}
}
fn knowledge_document_alias_var_keys(
knowledge_ref: &KnowledgeRef,
doc: &KnowledgeDocManifest,
) -> Vec<String> {
let mut keys = Vec::new();
let pack_prefix = knowledge_ref.prompt_prefix();
let selector = prompt_doc_selector(doc);
let Some((parent, _leaf)) = selector
.strip_suffix(".md")
.unwrap_or(selector.as_str())
.rsplit_once(['.', '/'])
else {
return keys;
};
let parent = parent
.split(['.', '/'])
.filter(|segment| !segment.is_empty())
.map(normalize_var_segment)
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join(".");
if let Some(stripped) = doc.id.strip_prefix("nenjo.") {
let id_segments = stripped
.split('.')
.map(normalize_var_segment)
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
if id_segments.len() >= 2
&& id_segments
.first()
.is_some_and(|segment| segment == &parent)
{
let basename = id_segments[1..].join("_");
keys.push(format!("{pack_prefix}.{parent}.nenjo_{basename}"));
}
}
keys
}
fn normalize_var_segment(segment: &str) -> String {
let mut normalized = String::new();
let mut last_was_underscore = false;
for ch in segment.chars() {
let ch = ch.to_ascii_lowercase();
if ch.is_ascii_alphanumeric() {
normalized.push(ch);
last_was_underscore = false;
} else if !last_was_underscore {
normalized.push('_');
last_was_underscore = true;
}
}
normalized.trim_matches('_').to_string()
}
#[derive(Debug, Serialize)]
#[serde(rename = "knowledge_doc")]
struct KnowledgeDocMetadataContext<'a> {
#[serde(rename = "@pack")]
pack: &'a str,
#[serde(rename = "@selector")]
selector: &'a str,
#[serde(rename = "@title")]
title: &'a str,
#[serde(rename = "@kind")]
kind: &'a str,
summary: &'a str,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
tags: Vec<&'a str>,
#[serde(rename = "related", skip_serializing_if = "Vec::is_empty", default)]
related: Vec<KnowledgeDocumentRelatedSummaryContext<'a>>,
}
fn doc_metadata(knowledge_ref: &KnowledgeRef, doc: &KnowledgeDocManifest) -> String {
let selector = prompt_doc_selector(doc);
let pack = knowledge_ref.selector();
let ctx = KnowledgeDocMetadataContext {
pack: &pack,
selector: &selector,
title: &doc.title,
summary: &doc.summary,
kind: doc.kind.as_str(),
tags: doc.tags.iter().map(String::as_str).collect(),
related: doc
.related
.iter()
.map(|edge| KnowledgeDocumentRelatedSummaryContext {
edge_type: edge.edge_type.as_str(),
target: edge.target.as_str(),
})
.collect(),
};
nenjo_xml::to_xml_pretty(&ctx, 2)
}
fn prompt_doc_selector(doc: &KnowledgeDocManifest) -> String {
if doc.selector.starts_with("library://") {
doc.selector
.splitn(4, '/')
.nth(3)
.unwrap_or(&doc.selector)
.to_string()
} else {
doc.selector.clone()
}
}
fn pack_schema() -> serde_json::Value {
json!({
"type": "string",
"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>."
})
}
fn knowledge_filter_schema(
extra_properties: Option<serde_json::Value>,
required: &[&str],
) -> serde_json::Value {
let mut properties = json!({
"pack": pack_schema(),
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Optional tags that all returned docs must have"
},
"kind": {
"type": "string",
"description": "Optional kind filter such as guide or reference"
},
"selector_prefix": {
"type": "string",
"description": "Optional virtual or pack-relative selector prefix"
},
"related_to": {
"type": "string",
"description": "Optional selector of a document this result must be related to"
},
"edge_type": {
"type": "string",
"description": "Optional relationship type used with related_to or neighbors"
}
});
if let Some(extra) = extra_properties
&& let Some(map) = properties.as_object_mut()
&& let Some(extra_map) = extra.as_object()
{
for (key, value) in extra_map {
map.insert(key.clone(), value.clone());
}
}
json!({
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": false
})
}
fn knowledge_lookup_schema() -> serde_json::Value {
json!({
"type": "object",
"properties": {
"pack": pack_schema(),
"selector": {
"type": "string",
"description": "Document selector or id within the selected pack"
}
},
"required": ["pack", "selector"],
"additionalProperties": false
})
}
pub fn knowledge_tools() -> Vec<ToolSpec> {
vec![
ToolSpec {
name: "list_knowledge_packs".into(),
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(),
parameters: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
category: ToolCategory::Read,
},
ToolSpec {
name: "read_knowledge_doc".into(),
description: "Read one full document body from a knowledge pack by path, selector, or document slug. The returned document.slug is the stable slug to use with update_knowledge_doc, delete_knowledge_doc, or related.target_doc.".into(),
parameters: knowledge_lookup_schema(),
category: ToolCategory::Read,
},
ToolSpec {
name: "search_knowledge".into(),
description: "Search a knowledge pack and return candidate document metadata without loading document bodies. Each result includes document.slug, the stable slug to use with update_knowledge_doc, delete_knowledge_doc, or related.target_doc.".into(),
parameters: knowledge_filter_schema(
Some(json!({
"query": {
"type": "string",
"description": "Search query, path, title, tag, or summary"
}
})),
&["pack", "query"],
),
category: ToolCategory::Read,
},
ToolSpec {
name: "list_knowledge_neighbors".into(),
description: "List outbound graph neighbors for one document in a knowledge pack.".into(),
parameters: json!({
"type": "object",
"properties": {
"pack": pack_schema(),
"selector": {
"type": "string",
"description": "Document selector or id within the selected pack"
},
"edge_type": {
"type": "string",
"description": "Optional relationship type filter such as references or depends_on"
}
},
"required": ["pack", "selector"],
"additionalProperties": false
}),
category: ToolCategory::Read,
},
]
}
fn knowledge_tool_spec(name: &str) -> ToolSpec {
knowledge_tools()
.into_iter()
.find(|tool| tool.name == name)
.unwrap_or_else(|| panic!("missing knowledge tool spec: {name}"))
}
pub fn knowledge_list_packs_tool(registry: Arc<dyn KnowledgeRegistry>) -> Arc<dyn Tool> {
Arc::new(KnowledgeTool::new(
knowledge_tool_spec("list_knowledge_packs"),
registry,
))
}
pub fn knowledge_traversal_tools(registry: Arc<dyn KnowledgeRegistry>) -> Vec<Arc<dyn Tool>> {
knowledge_tools()
.into_iter()
.filter(|spec| spec.name != "list_knowledge_packs")
.map(|spec| Arc::new(KnowledgeTool::new(spec, registry.clone())) as Arc<dyn Tool>)
.collect()
}
pub fn knowledge_toolbelt(registry: Arc<dyn KnowledgeRegistry>) -> Vec<Arc<dyn Tool>> {
let mut tools = vec![knowledge_list_packs_tool(registry.clone())];
tools.extend(knowledge_traversal_tools(registry));
tools
}
struct KnowledgeTool {
spec: ToolSpec,
registry: Arc<dyn KnowledgeRegistry>,
}
impl KnowledgeTool {
fn new(spec: ToolSpec, registry: Arc<dyn KnowledgeRegistry>) -> Self {
Self { spec, registry }
}
}
#[async_trait]
impl Tool for KnowledgeTool {
fn name(&self) -> &str {
&self.spec.name
}
fn description(&self) -> &str {
&self.spec.description
}
fn parameters_schema(&self) -> serde_json::Value {
self.spec.parameters.clone()
}
fn category(&self) -> ToolCategory {
self.spec.category
}
fn origin(&self) -> ToolOrigin {
ToolOrigin::Platform
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
let output = match self.name() {
"list_knowledge_packs" => serde_json::to_value(self.registry.list_packs().await?)?,
"read_knowledge_doc" => {
let args: KnowledgeReadArgs = serde_json::from_value(args)?;
let pack = self.registry.resolve_pack(&args.pack).await?;
let doc = pack.read_doc(&args.selector).ok_or_else(|| {
anyhow!(
"knowledge document '{}' not found in pack '{}'",
args.selector,
args.pack
)
})?;
serde_json::to_value(KnowledgeDocReadResult {
document: knowledge_document_metadata(
args.pack,
&doc.manifest,
Some(pack.as_ref()),
),
content: doc.content,
})?
}
"search_knowledge" => {
let args: KnowledgeSearchArgs = serde_json::from_value(args)?;
let pack = self.registry.resolve_pack(&args.pack).await?;
let filter = knowledge_filter(args.filter)?;
let hits = pack
.search(&args.query, filter)
.into_iter()
.map(|hit| knowledge_search_result(args.pack.clone(), pack.as_ref(), hit))
.collect::<Vec<_>>();
serde_json::to_value(hits)?
}
"list_knowledge_neighbors" => {
let args: KnowledgeNeighborArgs = serde_json::from_value(args)?;
let pack = self.registry.resolve_pack(&args.pack).await?;
let edge_type = parse_knowledge_enum(args.edge_type)?;
let neighbors = pack.neighbors(&args.selector, edge_type).ok_or_else(|| {
anyhow!(
"knowledge document '{}' not found in pack '{}'",
args.selector,
args.pack
)
})?;
serde_json::to_value(knowledge_neighbors_result(
args.pack,
pack.as_ref(),
neighbors,
))?
}
name => return Err(anyhow!("unknown knowledge tool '{name}'")),
};
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&output)?,
error: None,
})
}
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use std::future::Future;
use std::task::{Context, Poll, Waker};
use super::{
CompositeKnowledgeRegistry, KnowledgeDocReadResult, KnowledgePackEntry, KnowledgeRef,
KnowledgeRegistry, knowledge_document_var_key, knowledge_list_packs_tool,
knowledge_neighbors_result, knowledge_search_result, knowledge_tools,
};
use crate::{
KnowledgeDocEdge, KnowledgeDocEdgeType, KnowledgeDocKind, KnowledgeDocManifest,
KnowledgePack, KnowledgePackManifest, KnowledgePackManifestData,
};
use serde_json::json;
struct TestPack {
manifest: KnowledgePackManifestData,
}
impl KnowledgePack for TestPack {
fn manifest(&self) -> &dyn KnowledgePackManifest {
&self.manifest
}
fn doc_content(&self, manifest: &KnowledgeDocManifest) -> Option<Cow<'_, str>> {
Some(Cow::Owned(format!("body for {}", manifest.title)))
}
}
fn block_on<F: Future>(future: F) -> F::Output {
let waker = Waker::noop();
let mut context = Context::from_waker(waker);
let mut future = Box::pin(future);
match future.as_mut().poll(&mut context) {
Poll::Ready(output) => output,
Poll::Pending => panic!("test future unexpectedly yielded"),
}
}
fn test_doc(
id: &str,
path: &str,
title: &str,
related: Vec<KnowledgeDocEdge>,
) -> KnowledgeDocManifest {
KnowledgeDocManifest {
id: id.into(),
selector: path.into(),
source_path: path.trim_start_matches("library://test/").into(),
title: title.into(),
summary: format!("{title} summary"),
kind: KnowledgeDocKind::new("routing-guide"),
tags: vec!["core".into()],
related,
updated_at: String::new(),
}
}
fn test_pack() -> TestPack {
TestPack {
manifest: KnowledgePackManifestData {
pack_id: "test".into(),
version: "1".into(),
schema_version: 1,
root_uri: "library://test/".into(),
content_hash: String::new(),
docs: vec![
test_doc(
"root",
"library://test/root.md",
"Root",
vec![KnowledgeDocEdge {
edge_type: KnowledgeDocEdgeType::DependsOn,
target: "library://test/leaf.md".into(),
description: Some("root to leaf".into()),
}],
),
test_doc(
"leaf",
"library://test/leaf.md",
"Leaf",
vec![KnowledgeDocEdge {
edge_type: KnowledgeDocEdgeType::References,
target: "library://test/root.md".into(),
description: Some("reverse edge".into()),
}],
),
],
},
}
}
#[test]
fn composite_registry_routes_builtin_knowledge_namespaces() {
block_on(async {
let registry = CompositeKnowledgeRegistry::new()
.with_entry(KnowledgePackEntry::library("docs", test_pack()).unwrap())
.with_entry(KnowledgePackEntry::package("nenjo/core", test_pack()).unwrap())
.with_entry(KnowledgePackEntry::local("scratch", test_pack()).unwrap());
let packs = registry.list_packs().await.unwrap();
let selectors = packs
.iter()
.map(|pack| pack.selector.as_str())
.collect::<Vec<_>>();
assert_eq!(
selectors,
vec!["lib:docs", "local:scratch", "pkg:nenjo.core"]
);
assert_eq!(
registry
.resolve_pack("lib:docs")
.await
.unwrap()
.manifest()
.pack_id(),
"test"
);
assert!(registry.resolve_pack("pkg:nenjo.core").await.is_ok());
assert!(registry.resolve_pack("local:scratch").await.is_ok());
});
}
#[test]
fn list_knowledge_packs_tool_works_with_empty_registry() {
block_on(async {
let registry = std::sync::Arc::new(CompositeKnowledgeRegistry::new());
let tool = knowledge_list_packs_tool(registry);
assert_eq!(tool.name(), "list_knowledge_packs");
let result = tool.execute(serde_json::json!({})).await.unwrap();
assert!(result.success);
let packs: Vec<serde_json::Value> = serde_json::from_str(&result.output).unwrap();
assert!(packs.is_empty());
});
}
#[test]
fn default_knowledge_tool_registry_exposes_graph_first_tools_only() {
let names = knowledge_tools()
.into_iter()
.map(|tool| tool.name)
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"list_knowledge_packs",
"read_knowledge_doc",
"search_knowledge",
"list_knowledge_neighbors",
]
);
}
#[test]
fn knowledge_pack_summary_returns_selector_for_tool_calls() {
let pack = test_pack();
let summary = super::KnowledgePackSummary::new(
"pkg:nenjo-ai.packages.knowledge.core",
pack.manifest(),
);
assert_eq!(summary.selector, "pkg:nenjo-ai.packages.knowledge.core");
assert_eq!(summary.pack, summary.selector);
}
#[test]
fn pack_prompt_summary_includes_compact_related_edges() {
let pack = TestPack {
manifest: KnowledgePackManifestData {
pack_id: "test".into(),
version: "1".into(),
schema_version: 1,
root_uri: "file:///tmp/test/".into(),
content_hash: String::new(),
docs: vec![
test_doc(
"root",
"docs/root.md",
"Root",
vec![KnowledgeDocEdge {
edge_type: KnowledgeDocEdgeType::DependsOn,
target: "docs/leaf.md".into(),
description: Some("root to leaf".into()),
}],
),
test_doc(
"leaf",
"docs/leaf.md",
"Leaf",
vec![KnowledgeDocEdge {
edge_type: KnowledgeDocEdgeType::References,
target: "docs/root.md".into(),
description: Some("reverse edge".into()),
}],
),
],
},
};
let knowledge_ref = KnowledgeRef::local("test").unwrap();
let summary = super::knowledge_pack_summary(&knowledge_ref, &pack);
assert!(summary.contains(r#"selector="local:test""#));
assert!(summary.contains(r#"<related type="depends_on" target="docs/leaf.md""#));
assert!(summary.contains(r#"<related type="references" target="docs/root.md""#));
assert!(!summary.contains("root to leaf"));
assert!(!summary.contains("reverse edge"));
}
#[test]
fn document_metadata_prompt_var_includes_related_edges() {
let doc = test_doc(
"root",
"docs/root.md",
"Root",
vec![KnowledgeDocEdge {
edge_type: KnowledgeDocEdgeType::DependsOn,
target: "docs/leaf.md".into(),
description: Some("root to leaf".into()),
}],
);
let knowledge_ref = KnowledgeRef::local("test").unwrap();
let metadata = super::doc_metadata(&knowledge_ref, &doc);
assert!(metadata.contains(r#"pack="local:test""#));
assert!(metadata.contains(r#"selector="docs/root.md""#));
assert!(metadata.contains(r#"<related type="depends_on" target="docs/leaf.md""#));
assert!(!metadata.contains("root to leaf"));
}
#[test]
fn neighbor_traversal_returns_outbound_edges_with_slim_target_metadata() {
let pack = test_pack();
let result = pack
.neighbors("root", None)
.map(|neighbors| knowledge_neighbors_result("lib:test", &pack, neighbors))
.expect("root neighbors");
let value = serde_json::to_value(result).unwrap();
assert_eq!(value["document"]["selector"], "library://test/root.md");
assert_eq!(value["document"]["slug"], "root");
assert!(value["document"].get("id").is_none());
assert!(value["document"].get("doc").is_none());
assert_eq!(value["document"]["related"][0]["type"], "depends_on");
assert_eq!(
value["document"]["related"][0]["target"],
"library://test/leaf.md"
);
assert_eq!(value["document"]["related"][0]["target_doc"], "leaf");
assert_eq!(value["edges"].as_array().unwrap().len(), 1);
assert_eq!(value["edges"][0]["type"], "depends_on");
assert_eq!(
value["edges"][0]["target"]["selector"],
"library://test/leaf.md"
);
assert_eq!(value["edges"][0]["target"]["slug"], "leaf");
assert_eq!(value["edges"][0]["target"]["kind"], "routing_guide");
assert!(value["edges"][0]["target"].get("source_path").is_none());
assert!(value["edges"][0].get("note").is_none());
}
#[test]
fn search_returns_slim_metadata_without_content() {
let pack = test_pack();
let value = serde_json::to_value(
pack.search("Leaf", Default::default())
.into_iter()
.map(|hit| knowledge_search_result("lib:test", &pack, hit))
.collect::<Vec<_>>(),
)
.unwrap();
assert_eq!(value[0]["document"]["selector"], "library://test/leaf.md");
assert_eq!(value[0]["document"]["slug"], "leaf");
assert!(value[0]["document"].get("id").is_none());
assert!(value[0]["document"].get("doc").is_none());
assert_eq!(value[0]["document"]["related"][0]["type"], "references");
assert_eq!(
value[0]["document"]["related"][0]["target"],
"library://test/root.md"
);
assert_eq!(value[0]["document"]["related"][0]["target_doc"], "root");
assert!(
value[0]["matched"]
.as_array()
.unwrap()
.contains(&json!("title"))
);
assert!(
!value[0]["matched"]
.as_array()
.unwrap()
.contains(&json!("content"))
);
assert!(value[0].get("content").is_none());
assert!(value[0]["document"].get("aliases").is_none());
}
#[test]
fn read_knowledge_doc_result_keeps_full_content_explicit() {
let pack = test_pack();
let doc = pack.read_doc("leaf").expect("leaf doc");
let value = serde_json::to_value(KnowledgeDocReadResult {
document: super::knowledge_document_metadata("lib:test", &doc.manifest, Some(&pack)),
content: doc.content,
})
.unwrap();
assert_eq!(value["document"]["pack"], "lib:test");
assert_eq!(value["document"]["selector"], "library://test/leaf.md");
assert_eq!(value["document"]["slug"], "leaf");
assert!(value["document"].get("id").is_none());
assert!(value["document"].get("doc").is_none());
assert_eq!(
value["document"]["related"][0]["target"],
"library://test/root.md"
);
assert_eq!(value["document"]["related"][0]["target_doc"], "root");
assert_eq!(value["content"], "body for Leaf");
}
#[test]
fn library_knowledge_uses_lib_template_namespace() {
let knowledge_ref = KnowledgeRef::library("product-docs").unwrap();
assert_eq!(knowledge_ref.selector(), "lib:product-docs");
assert_eq!(knowledge_ref.prompt_prefix(), "lib.product_docs");
}
#[test]
fn pkg_knowledge_uses_package_template_namespace() {
let knowledge_ref = KnowledgeRef::package("@nenjo/core").unwrap();
assert_eq!(knowledge_ref.selector(), "pkg:nenjo.core");
assert_eq!(knowledge_ref.prompt_prefix(), "pkg.nenjo.core");
}
#[test]
fn pkg_knowledge_document_vars_use_package_relative_paths() {
let knowledge_ref = KnowledgeRef::package("nenjo.core").unwrap();
let doc = KnowledgeDocManifest {
id: "nenjo.resources.agents".into(),
selector: "resources.agents".into(),
source_path: "docs/resources/agents.md".into(),
title: "Agents".into(),
summary: String::new(),
kind: KnowledgeDocKind::new("guide"),
tags: Vec::new(),
related: Vec::new(),
updated_at: String::new(),
};
assert_eq!(
knowledge_document_var_key(&knowledge_ref, &doc),
"pkg.nenjo.core.resources.agents"
);
}
}