pub(crate) mod descriptions;
#[cfg(feature = "summarize")]
pub(crate) mod embed;
mod handlers;
pub(crate) mod ner;
pub use descriptions::all_tools;
use std::collections::BTreeMap;
use anyhow::{Result, anyhow, bail};
use ipld_core::ipld::Ipld;
use mnem_core::codec::from_canonical_bytes;
use mnem_core::objects::{IndexSet, RefTarget};
use mnem_core::repo::ReadonlyRepo;
use serde_json::Value;
use crate::server::Server;
pub(super) const MAX_RETRIEVE_LIMIT: usize = 1_000;
pub(super) const MAX_VECTOR_CAP: usize = 100_000;
pub(super) const MAX_RERANK_TOP_K: usize = 500;
pub(crate) fn dispatch(server: &mut Server, name: &str, args: Value) -> Result<String> {
match name {
"mnem_stats" => handlers::stats::stats(server),
"mnem_schema" => handlers::schema::schema(server),
"mnem_search" => handlers::search::search(server, args),
"mnem_get_node" => handlers::get_node::get_node(server, args),
"mnem_traverse" => handlers::traverse::traverse(server, args),
"mnem_commit" => handlers::commit::commit(server, args),
"mnem_commit_relation" => handlers::commit_relation::commit_relation(server, args),
"mnem_delete_node" => handlers::delete_node::delete_node(server, args),
"mnem_tombstone_node" => handlers::tombstone_node::tombstone_node(server, args),
"mnem_list_nodes" => handlers::list_nodes::list_nodes(server, args),
"mnem_resolve_or_create" => handlers::resolve_or_create::resolve_or_create(server, args),
"mnem_recent" => handlers::recent::recent(server, args),
"mnem_vector_search" => handlers::vector_search::vector_search(server, args),
"mnem_retrieve" => handlers::retrieve::retrieve(server, args),
"mnem_global_retrieve" => handlers::global_retrieve::global_retrieve(server, args),
"mnem_global_add" => handlers::global_add::global_add(server, args),
"mnem_global_ingest" => handlers::global_ingest::global_ingest(server, args),
"mnem_ingest" => handlers::ingest::ingest(server, args),
#[cfg(feature = "summarize")]
"mnem_community_summarize" => {
handlers::community_summarize::community_summarize(server, args)
}
other => bail!("unknown tool: {other}"),
}
}
pub(super) fn preview_str(s: &str) -> String {
if s.len() <= 100 {
s.to_string()
} else {
let preview: String = s.chars().take(97).collect();
format!("{preview}... ({} bytes)", s.len())
}
}
pub(super) fn index_set(server: &mut Server, repo: &ReadonlyRepo) -> Result<Option<IndexSet>> {
let Some(idx_cid) = repo.head_commit().and_then(|c| c.indexes.as_ref()) else {
return Ok(None);
};
let bs = server.stores()?.0;
let bytes = bs
.get(idx_cid)?
.ok_or_else(|| anyhow!("IndexSet block {idx_cid} missing"))?;
Ok(Some(from_canonical_bytes(&bytes)?))
}
pub(super) fn summarize_refs(refs: &BTreeMap<String, RefTarget>) -> String {
if refs.is_empty() {
return "none".to_string();
}
let names: Vec<&str> = refs.keys().take(5).map(String::as_str).collect();
let mut s = names.join(", ");
if refs.len() > 5 {
s.push_str(&format!(", +{} more", refs.len() - 5));
}
s
}
pub(super) fn ipld_preview(v: &Ipld) -> String {
match v {
Ipld::Null => "null".into(),
Ipld::Bool(b) => b.to_string(),
Ipld::Integer(n) => n.to_string(),
Ipld::Float(f) => f.to_string(),
Ipld::String(s) => {
if s.len() <= 80 {
format!("\"{s}\"")
} else {
let preview: String = s.chars().take(77).collect();
format!("\"{preview}...\" ({} bytes)", s.len())
}
}
Ipld::Bytes(b) => format!("bytes({})", b.len()),
Ipld::List(xs) => format!("[{} items]", xs.len()),
Ipld::Map(m) => format!("{{{} keys}}", m.len()),
Ipld::Link(c) => format!("cid:{c}"),
}
}
#[cfg(test)]
mod mnem_bench_gate_tests {
use super::*;
use crate::server::Server;
use mnem_core::objects::Node;
use serde_json::json;
use tempfile::TempDir;
fn mk_server(allow_labels: bool) -> (Server, TempDir) {
let td = tempfile::tempdir().expect("tempdir");
let mut s = Server::new(td.path().to_path_buf());
s.allow_labels = allow_labels;
(s, td)
}
#[test]
fn schemas_are_stable_regardless_of_gate() {
let gate_off = all_tools(false);
let gate_on = all_tools(true);
let off_names: Vec<_> = gate_off.iter().map(|t| t.name).collect();
let on_names: Vec<_> = gate_on.iter().map(|t| t.name).collect();
assert_eq!(off_names, on_names, "tool name list must be stable");
for (a, b) in gate_off.iter().zip(gate_on.iter()) {
assert_eq!(a.name, b.name);
assert_eq!(
serde_json::to_string(&a.input_schema).unwrap(),
serde_json::to_string(&b.input_schema).unwrap(),
"schema for `{}` must be byte-identical regardless of allow_labels",
a.name
);
}
}
#[test]
fn schemas_always_expose_label_and_ntype() {
let tools = all_tools(false);
let by_name: std::collections::BTreeMap<_, _> =
tools.iter().map(|t| (t.name, &t.input_schema)).collect();
for name in [
"mnem_search",
"mnem_list_nodes",
"mnem_resolve_or_create",
"mnem_retrieve",
] {
let schema = by_name.get(name).expect("tool present");
let rendered = serde_json::to_string(schema).unwrap();
assert!(
rendered.contains("\"label\""),
"{name}: schema should always expose `label`; got: {rendered}"
);
}
let commit_schema = by_name.get("mnem_commit").expect("mnem_commit present");
let commit_rendered = serde_json::to_string(commit_schema).unwrap();
assert!(
commit_rendered.contains("\"ntype\""),
"mnem_commit: nodes.items schema should always expose `ntype`; got: {commit_rendered}"
);
}
#[test]
fn gate_off_forces_ntype_to_default_on_commit() {
let (mut s, _td) = mk_server(false);
let out = dispatch(
&mut s,
"mnem_commit",
json!({
"agent_id": "tester",
"nodes": [
{ "ntype": "SecretLabel", "summary": "nope" }
]
}),
)
.expect("commit ok");
assert!(
out.contains(&format!("- {} ", Node::DEFAULT_NTYPE)),
"expected default ntype in output when MNEM_BENCH off; got: {out}"
);
assert!(
!out.contains("- SecretLabel "),
"caller-supplied ntype must not leak through when MNEM_BENCH off; got: {out}"
);
}
#[test]
fn gate_on_honours_caller_ntype_on_commit() {
let (mut s, _td) = mk_server(true);
let out = dispatch(
&mut s,
"mnem_commit",
json!({
"agent_id": "tester",
"nodes": [
{ "ntype": "Person", "summary": "alice" }
]
}),
)
.expect("commit ok");
assert!(
out.contains("- Person "),
"expected caller ntype to survive when MNEM_BENCH on; got: {out}"
);
}
#[test]
fn gate_off_drops_label_filter_on_list_nodes() {
let (mut s, _td) = mk_server(false);
dispatch(
&mut s,
"mnem_commit",
json!({
"agent_id": "tester",
"nodes": [
{ "ntype": "A", "summary": "a1" },
{ "ntype": "B", "summary": "b1" }
]
}),
)
.expect("seed ok");
let out = dispatch(
&mut s,
"mnem_list_nodes",
json!({ "label": "DoesNotExist" }),
)
.expect("list ok");
assert!(
out.contains("2 item(s)") || out.contains("item(s) (across all labels)"),
"expected label filter dropped when MNEM_BENCH off; got: {out}"
);
}
#[test]
fn gate_on_honours_label_filter_on_list_nodes() {
let (mut s, _td) = mk_server(true);
dispatch(
&mut s,
"mnem_commit",
json!({
"agent_id": "tester",
"nodes": [
{ "ntype": "Person", "summary": "p1" },
{ "ntype": "Doc", "summary": "d1" }
]
}),
)
.expect("seed ok");
let out = dispatch(&mut s, "mnem_list_nodes", json!({ "label": "Doc" })).expect("list ok");
assert!(
out.contains("label=Doc"),
"expected label= tag in header when MNEM_BENCH on; got: {out}"
);
}
#[test]
fn gate_off_collapses_label_in_resolve_or_create() {
let (mut s, _td) = mk_server(false);
let v1 = dispatch(
&mut s,
"mnem_resolve_or_create",
json!({
"label": "Person",
"prop_name": "name",
"value": "Alice",
"agent_id": "tester"
}),
)
.expect("first resolve ok");
let v2 = dispatch(
&mut s,
"mnem_resolve_or_create",
json!({
"label": "Robot",
"prop_name": "name",
"value": "Alice",
"agent_id": "tester"
}),
)
.expect("second resolve ok");
assert!(
v1.contains(&format!("label: {}", Node::DEFAULT_NTYPE))
&& v2.contains(&format!("label: {}", Node::DEFAULT_NTYPE)),
"both resolve_or_create calls should land on default ntype when MNEM_BENCH off; got v1={v1} v2={v2}"
);
}
#[test]
fn retrieve_rejects_oversized_limit() {
let (mut s, _td) = mk_server(false);
let err = dispatch(&mut s, "mnem_retrieve", json!({ "limit": 99_999_999_u64 }))
.expect_err("oversized limit must be rejected");
let msg = format!("{err:#}");
assert!(
msg.contains("limit=") && msg.contains("exceeds max"),
"error must name the knob + cap: {msg}"
);
}
#[test]
fn retrieve_rejects_oversized_vector_cap() {
let (mut s, _td) = mk_server(false);
let err = dispatch(
&mut s,
"mnem_retrieve",
json!({ "vector_cap": 9_999_999_u64 }),
)
.expect_err("oversized vector_cap must be rejected");
let msg = format!("{err:#}");
assert!(
msg.contains("vector_cap=") && msg.contains("exceeds max"),
"error must name the knob + cap: {msg}"
);
}
#[test]
fn commit_relation_creates_two_nodes_and_one_edge() {
let (mut s, _td) = mk_server(true);
let out = dispatch(
&mut s,
"mnem_commit_relation",
json!({
"subject": "Alice",
"subject_kind": "Entity:Person",
"predicate": "works_at",
"object": "Globex",
"object_kind": "Entity:Organization",
"agent_id": "g6-test"
}),
)
.expect("commit_relation ok");
assert!(out.contains("subject:"), "missing subject line: {out}");
assert!(out.contains("Entity:Person"), "subject ntype absent: {out}");
assert!(
out.contains("predicate: works_at"),
"predicate line absent: {out}"
);
assert!(
out.contains("Entity:Organization"),
"object ntype absent: {out}"
);
assert!(out.contains("Alice"), "subject value absent: {out}");
assert!(out.contains("Globex"), "object value absent: {out}");
}
#[test]
fn commit_relation_dedups_existing_subject() {
let (mut s, _td) = mk_server(true);
let out1 = dispatch(
&mut s,
"mnem_commit_relation",
json!({
"subject": "Alice",
"subject_kind": "Entity:Person",
"predicate": "works_at",
"object": "Globex",
"object_kind": "Entity:Organization",
"agent_id": "g6-test"
}),
)
.expect("first ok");
let out2 = dispatch(
&mut s,
"mnem_commit_relation",
json!({
"subject": "Alice",
"subject_kind": "Entity:Person",
"predicate": "lives_in",
"object": "Berlin",
"object_kind": "Entity:Place",
"agent_id": "g6-test"
}),
)
.expect("second ok");
let extract_subject_uuid = |s: &str| {
for line in s.lines() {
if let Some(rest) = line.trim_start().strip_prefix("subject:") {
return rest.split_whitespace().next().map(String::from);
}
}
None
};
let u1 = extract_subject_uuid(&out1).expect("subject uuid present in out1");
let u2 = extract_subject_uuid(&out2).expect("subject uuid present in out2");
assert_eq!(
u1, u2,
"second commit_relation should reuse the same Alice node"
);
}
#[test]
fn commit_relation_missing_predicate_returns_error() {
let (mut s, _td) = mk_server(true);
let err = dispatch(
&mut s,
"mnem_commit_relation",
json!({
"subject": "Alice",
"object": "Globex"
}),
)
.expect_err("must reject missing predicate");
let msg = format!("{err:#}");
assert!(
msg.contains("predicate"),
"error must mention predicate: {msg}"
);
}
#[test]
fn commit_relation_respects_label_gate_off() {
let (mut s, _td) = mk_server(false);
let out = dispatch(
&mut s,
"mnem_commit_relation",
json!({
"subject": "Alice",
"subject_kind": "Entity:Person",
"predicate": "works_at",
"object": "Globex",
"object_kind": "Entity:Organization",
"agent_id": "g6-test"
}),
)
.expect("commit_relation ok");
assert!(
!out.contains("Entity:Person") && !out.contains("Entity:Organization"),
"caller-supplied kinds must NOT survive gate-off: {out}"
);
assert!(
out.contains(&format!("[{}]", Node::DEFAULT_NTYPE)),
"endpoints must use DEFAULT_NTYPE under gate-off: {out}"
);
}
#[test]
fn retrieve_rejects_oversized_rerank_top_k() {
let (mut s, _td) = mk_server(false);
let err = dispatch(
&mut s,
"mnem_retrieve",
json!({ "rerank_top_k": 10_000_u64 }),
)
.expect_err("oversized rerank_top_k must be rejected");
let msg = format!("{err:#}");
assert!(
msg.contains("rerank_top_k=") && msg.contains("exceeds max"),
"error must name the knob + cap: {msg}"
);
}
}