use crate::models::field_names;
use std::sync::Arc;
use serde_json::{Value, json};
use crate::atomisation::{AtomiseError, Atomiser};
use crate::config::FeatureTier;
use crate::mcp::param_names;
pub struct AtomiseToolHandler {
pub atomiser: Arc<Atomiser>,
#[allow(dead_code)]
pub tier: FeatureTier,
}
impl AtomiseToolHandler {
#[must_use]
pub fn new(atomiser: Arc<Atomiser>, tier: FeatureTier) -> Self {
Self { atomiser, tier }
}
}
const REQUIRED_TIER: &str = "smart";
pub fn handle_atomise(
conn: &rusqlite::Connection,
params: &Value,
handler: Option<&AtomiseToolHandler>,
tier: FeatureTier,
mcp_client: Option<&str>,
) -> Result<Value, String> {
let memory_id = params
.get(param_names::MEMORY_ID)
.ok_or(crate::errors::msg::MEMORY_ID_REQUIRED)?
.as_str()
.ok_or("memory_id must be a string")?;
if memory_id.is_empty() {
return Err("memory_id must not be empty".to_string());
}
let max_atom_tokens: u32 = if let Some(v) = params.get(param_names::MAX_ATOM_TOKENS) {
if v.is_null() {
crate::atomisation::DEFAULT_ATOM_TOKENS
} else {
let n = v.as_i64().ok_or_else(|| {
format!(
"max_atom_tokens must be an integer in [{}, {}] (default {})",
crate::atomisation::MIN_ATOM_TOKENS,
crate::atomisation::MAX_ATOM_TOKENS,
crate::atomisation::DEFAULT_ATOM_TOKENS
)
})?;
if !(i64::from(crate::atomisation::MIN_ATOM_TOKENS)
..=i64::from(crate::atomisation::MAX_ATOM_TOKENS))
.contains(&n)
{
return Err(format!(
"max_atom_tokens out of range [{}, {}]: {n}",
crate::atomisation::MIN_ATOM_TOKENS,
crate::atomisation::MAX_ATOM_TOKENS
));
}
u32::try_from(n).expect("range-checked above")
}
} else {
crate::atomisation::DEFAULT_ATOM_TOKENS
};
let force_re_atomise: bool = if let Some(v) = params.get(param_names::FORCE_RE_ATOMISE) {
if v.is_null() {
false
} else {
v.as_bool().ok_or("force_re_atomise must be a boolean")?
}
} else {
false
};
if tier == FeatureTier::Keyword || handler.is_none() {
return Ok(json!({
(field_names::TIER_LOCKED): "memory_atomise requires smart tier or higher",
(field_names::CURRENT_TIER): tier.as_str(),
(field_names::REQUIRED_TIER): REQUIRED_TIER,
}));
}
let handler = handler.expect("checked above");
let explicit_agent_id = params.get(param_names::AGENT_ID).and_then(Value::as_str);
let calling_agent_id = crate::identity::resolve_agent_id(explicit_agent_id, mcp_client)
.map_err(|e| e.to_string())?;
match handler.atomiser.atomise_sync(
conn,
memory_id,
max_atom_tokens,
force_re_atomise,
&calling_agent_id,
) {
Ok(result) => Ok(json!({
"source_id": result.source_id,
"atom_ids": result.atom_ids,
(field_names::ATOM_COUNT): result.atom_count,
(field_names::ARCHIVED_AT): result.archived_at,
})),
Err(AtomiseError::NotFound) => Err(format!("MEMORY_NOT_FOUND: {memory_id}")),
Err(AtomiseError::AlreadyAtomised {
source_id,
existing_atom_ids,
}) => Ok(json!({
"already_atomised": true,
"source_id": source_id,
"existing_atom_ids": existing_atom_ids,
(field_names::ATOM_COUNT): existing_atom_ids.len(),
})),
Err(AtomiseError::TierLocked) => Ok(json!({
(field_names::TIER_LOCKED): "memory_atomise requires smart tier or higher",
(field_names::CURRENT_TIER): tier.as_str(),
(field_names::REQUIRED_TIER): REQUIRED_TIER,
})),
Err(AtomiseError::CuratorFailed(detail)) => Err(format!("CURATOR_FAILED: {detail}")),
Err(AtomiseError::SourceTooSmall) => Ok(json!({
"source_too_small": true,
"source_id": memory_id,
"message": "source body is already at or under max_atom_tokens — no decomposition possible",
})),
Err(AtomiseError::GovernanceRefused(detail)) => {
Err(format!("GOVERNANCE_REFUSED: {detail}"))
}
Err(AtomiseError::SignerError(detail)) => Err(format!("SIGNER_ERROR: {detail}")),
Err(AtomiseError::DbError(detail)) => Err(format!("DB_ERROR: {detail}")),
Err(AtomiseError::DepthExceeded { attempted, cap }) => Err(format!(
"ATOMISATION_DEPTH_EXCEEDED: atomisation depth {attempted} would exceed \
compiled max_atomisation_depth {cap}"
)),
}
}
use crate::mcp::registry::McpTool;
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct AtomiseRequest {
pub memory_id: String,
#[serde(default)]
pub max_atom_tokens: Option<i64>,
#[serde(default)]
pub force_re_atomise: Option<bool>,
}
#[allow(dead_code)]
pub struct AtomiseTool;
impl McpTool for AtomiseTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_ATOMISE
}
fn description() -> &'static str {
"Decompose a memory into 2-10 atomic propositions; source archived. Smart+ tier."
}
fn docs() -> &'static str {
"WT-1-C: atomise via WT-1-B engine. Atoms = Observation memories with metadata.atom_source_id + derives_from link. Source archived (atomised_into=N). Returns {source_id, atom_ids, atom_count, archived_at}. Idempotent (use force_re_atomise to mint fresh). Too-small sources => {source_too_small:true}. Failures => CURATOR_FAILED / GOVERNANCE_REFUSED envelopes."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<AtomiseRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Power.name()
}
}
#[cfg(test)]
mod d1_5_986_tests {
use super::*;
use crate::mcp::parity_test_helpers::{
assert_descriptions_match, assert_property_set_parity, derived_props_for,
};
#[test]
fn atomise_parity_986() {
let derived = derived_props_for::<AtomiseRequest>();
assert_property_set_parity("memory_atomise", &derived);
assert_descriptions_match("memory_atomise", &derived);
}
#[test]
fn atomise_tool_metadata_986() {
assert_eq!(AtomiseTool::name(), "memory_atomise");
assert_eq!(AtomiseTool::family(), "power");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::atomisation::AtomiserConfig;
use crate::atomisation::curator::{Atom, Curator, CuratorError};
use crate::storage as db;
use std::sync::Mutex;
use tempfile::NamedTempFile;
struct MockCurator {
responses: Mutex<Vec<Result<Vec<Atom>, CuratorError>>>,
}
impl MockCurator {
fn new(responses: Vec<Result<Vec<Atom>, CuratorError>>) -> Self {
Self {
responses: Mutex::new(responses),
}
}
}
impl Curator for MockCurator {
fn decompose(
&self,
_body: &str,
_max_atom_tokens: u32,
_max_retries: u32,
) -> Result<Vec<Atom>, CuratorError> {
let mut q = self.responses.lock().unwrap();
if q.is_empty() {
return Err(CuratorError::MalformedResponse(
"mock: queue exhausted".into(),
));
}
q.remove(0)
}
}
fn fresh_db() -> (NamedTempFile, rusqlite::Connection) {
let tmp = NamedTempFile::new().expect("tempfile");
let conn = db::open(tmp.path()).expect("db::open");
(tmp, conn)
}
fn handler(tier: FeatureTier) -> AtomiseToolHandler {
let curator: Box<dyn Curator> = Box::new(MockCurator::new(vec![]));
let atomiser = Arc::new(Atomiser::new(
curator,
None,
AtomiserConfig::default(),
tier,
));
AtomiseToolHandler::new(atomiser, tier)
}
#[test]
fn missing_memory_id_errors() {
let (_tmp, conn) = fresh_db();
let h = handler(FeatureTier::Smart);
let err =
handle_atomise(&conn, &json!({}), Some(&h), FeatureTier::Smart, None).unwrap_err();
assert!(err.contains("memory_id is required"), "got: {err}");
}
#[test]
fn non_string_memory_id_errors() {
let (_tmp, conn) = fresh_db();
let h = handler(FeatureTier::Smart);
let err = handle_atomise(
&conn,
&json!({"memory_id": 42}),
Some(&h),
FeatureTier::Smart,
None,
)
.unwrap_err();
assert!(err.contains("must be a string"), "got: {err}");
}
#[test]
fn empty_memory_id_errors() {
let (_tmp, conn) = fresh_db();
let h = handler(FeatureTier::Smart);
let err = handle_atomise(
&conn,
&json!({"memory_id": ""}),
Some(&h),
FeatureTier::Smart,
None,
)
.unwrap_err();
assert!(err.contains("must not be empty"), "got: {err}");
}
#[test]
fn max_atom_tokens_zero_rejected() {
let (_tmp, conn) = fresh_db();
let h = handler(FeatureTier::Smart);
let err = handle_atomise(
&conn,
&json!({"memory_id": "11111111-2222-3333-4444-555555555555", "max_atom_tokens": 0}),
Some(&h),
FeatureTier::Smart,
None,
)
.unwrap_err();
assert!(err.contains("out of range"), "got: {err}");
}
#[test]
fn max_atom_tokens_below_range_rejected() {
let (_tmp, conn) = fresh_db();
let h = handler(FeatureTier::Smart);
let err = handle_atomise(
&conn,
&json!({"memory_id": "11111111-2222-3333-4444-555555555555", "max_atom_tokens": 49}),
Some(&h),
FeatureTier::Smart,
None,
)
.unwrap_err();
assert!(err.contains("out of range"), "got: {err}");
}
#[test]
fn max_atom_tokens_above_range_rejected() {
let (_tmp, conn) = fresh_db();
let h = handler(FeatureTier::Smart);
let err = handle_atomise(
&conn,
&json!({"memory_id": "11111111-2222-3333-4444-555555555555", "max_atom_tokens": 1001}),
Some(&h),
FeatureTier::Smart,
None,
)
.unwrap_err();
assert!(err.contains("out of range"), "got: {err}");
}
#[test]
fn max_atom_tokens_non_int_rejected() {
let (_tmp, conn) = fresh_db();
let h = handler(FeatureTier::Smart);
let err = handle_atomise(
&conn,
&json!({
"memory_id": "11111111-2222-3333-4444-555555555555",
"max_atom_tokens": "200"
}),
Some(&h),
FeatureTier::Smart,
None,
)
.unwrap_err();
assert!(err.contains("integer"), "got: {err}");
}
#[test]
fn force_re_atomise_string_rejected() {
let (_tmp, conn) = fresh_db();
let h = handler(FeatureTier::Smart);
let err = handle_atomise(
&conn,
&json!({
"memory_id": "11111111-2222-3333-4444-555555555555",
"force_re_atomise": "yes"
}),
Some(&h),
FeatureTier::Smart,
None,
)
.unwrap_err();
assert!(err.contains("boolean"), "got: {err}");
}
#[test]
fn keyword_tier_returns_tier_locked_advisory() {
let (_tmp, conn) = fresh_db();
let resp = handle_atomise(
&conn,
&json!({"memory_id": "11111111-2222-3333-4444-555555555555"}),
None,
FeatureTier::Keyword,
None,
)
.expect("tier-locked is informational, not an error");
assert_eq!(
resp["tier-locked"].as_str(),
Some("memory_atomise requires smart tier or higher")
);
assert_eq!(resp["current_tier"].as_str(), Some("keyword"));
assert_eq!(resp["required_tier"].as_str(), Some("smart"));
}
#[test]
fn handler_none_at_higher_tier_still_tier_locked() {
let (_tmp, conn) = fresh_db();
let resp = handle_atomise(
&conn,
&json!({"memory_id": "11111111-2222-3333-4444-555555555555"}),
None,
FeatureTier::Semantic,
None,
)
.expect("absence of handler collapses to tier-locked, not error");
assert!(resp["tier-locked"].is_string());
}
#[test]
fn memory_not_found_returns_typed_error() {
let (_tmp, conn) = fresh_db();
let h = handler(FeatureTier::Smart);
let err = handle_atomise(
&conn,
&json!({"memory_id": "11111111-2222-3333-4444-555555555555"}),
Some(&h),
FeatureTier::Smart,
None,
)
.unwrap_err();
assert!(err.starts_with("MEMORY_NOT_FOUND:"), "got: {err}");
}
fn handler_with(
tier: FeatureTier,
responses: Vec<Result<Vec<Atom>, CuratorError>>,
) -> AtomiseToolHandler {
let curator: Box<dyn Curator> = Box::new(MockCurator::new(responses));
let atomiser = Arc::new(Atomiser::new(
curator,
None,
AtomiserConfig::default(),
tier,
));
AtomiseToolHandler::new(atomiser, tier)
}
fn seed_big(conn: &rusqlite::Connection, ns: &str) -> String {
use crate::models::{Memory, MemoryKind, Tier};
let now = chrono::Utc::now().to_rfc3339();
let mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: ns.to_string(),
title: format!("src-{}", uuid::Uuid::new_v4().simple()),
content: "proposition token padding here. ".repeat(400),
created_at: now.clone(),
updated_at: now,
metadata: serde_json::json!({"agent_id": "ai:test"}),
memory_kind: MemoryKind::Observation,
..Default::default()
};
db::insert(conn, &mem).unwrap()
}
#[test]
fn successful_atomise_returns_atom_ids_and_count() {
let (_tmp, conn) = fresh_db();
let id = seed_big(&conn, "atomise-ok");
let h = handler_with(
FeatureTier::Smart,
vec![Ok(vec![
Atom {
text: "first proposition".into(),
},
Atom {
text: "second proposition".into(),
},
])],
);
let resp = handle_atomise(
&conn,
&json!({"memory_id": id, "max_atom_tokens": 50}),
Some(&h),
FeatureTier::Smart,
None,
)
.expect("atomise ok");
assert_eq!(resp["source_id"].as_str(), Some(id.as_str()));
assert!(resp["atom_count"].as_u64().unwrap() >= 2);
assert!(resp["atom_ids"].as_array().unwrap().len() >= 2);
}
#[test]
fn already_atomised_returns_existing_envelope() {
let (_tmp, conn) = fresh_db();
let id = seed_big(&conn, "atomise-twice");
let h = handler_with(
FeatureTier::Smart,
vec![Ok(vec![
Atom {
text: "a one".into(),
},
Atom {
text: "a two".into(),
},
])],
);
handle_atomise(
&conn,
&json!({"memory_id": id, "max_atom_tokens": 50}),
Some(&h),
FeatureTier::Smart,
None,
)
.expect("first atomise ok");
let resp = handle_atomise(
&conn,
&json!({"memory_id": id, "max_atom_tokens": 50}),
Some(&h),
FeatureTier::Smart,
None,
)
.expect("already-atomised is informational");
assert_eq!(resp["already_atomised"].as_bool(), Some(true));
assert_eq!(resp["source_id"].as_str(), Some(id.as_str()));
assert!(resp["existing_atom_ids"].as_array().unwrap().len() >= 2);
}
#[test]
fn source_too_small_returns_advisory() {
let (_tmp, conn) = fresh_db();
use crate::models::{Memory, MemoryKind, Tier};
let now = chrono::Utc::now().to_rfc3339();
let mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: "tiny".into(),
title: "tiny-src".into(),
content: "tiny".into(),
created_at: now.clone(),
updated_at: now,
metadata: serde_json::json!({"agent_id": "ai:test"}),
memory_kind: MemoryKind::Observation,
..Default::default()
};
let id = db::insert(&conn, &mem).unwrap();
let h = handler_with(FeatureTier::Smart, vec![]);
let resp = handle_atomise(
&conn,
&json!({"memory_id": id, "max_atom_tokens": 200}),
Some(&h),
FeatureTier::Smart,
None,
)
.expect("source-too-small is informational");
assert_eq!(resp["source_too_small"].as_bool(), Some(true));
assert_eq!(resp["source_id"].as_str(), Some(id.as_str()));
}
#[test]
fn curator_failure_returns_typed_error() {
let (_tmp, conn) = fresh_db();
let id = seed_big(&conn, "atomise-curfail");
let h = handler_with(
FeatureTier::Smart,
vec![Err(CuratorError::LlmUnavailable("down".into()))],
);
let err = handle_atomise(
&conn,
&json!({"memory_id": id, "max_atom_tokens": 50}),
Some(&h),
FeatureTier::Smart,
None,
)
.unwrap_err();
assert!(err.starts_with("CURATOR_FAILED:"), "got: {err}");
}
#[test]
fn null_max_atom_tokens_uses_default() {
let (_tmp, conn) = fresh_db();
let id = seed_big(&conn, "atomise-null");
let h = handler_with(
FeatureTier::Smart,
vec![Ok(vec![
Atom {
text: "n one".into(),
},
Atom {
text: "n two".into(),
},
])],
);
let resp = handle_atomise(
&conn,
&json!({"memory_id": id, "max_atom_tokens": null, "force_re_atomise": null}),
Some(&h),
FeatureTier::Smart,
None,
)
.expect("null tokens defaults cleanly");
assert!(resp["atom_count"].as_u64().unwrap() >= 2);
}
}