1use crate::models::field_names;
63use std::sync::Arc;
64
65use serde_json::{Value, json};
66
67use crate::atomisation::{AtomiseError, Atomiser};
68use crate::config::FeatureTier;
69use crate::mcp::param_names;
70
71pub struct AtomiseToolHandler {
79 pub atomiser: Arc<Atomiser>,
80 #[allow(dead_code)]
88 pub tier: FeatureTier,
89}
90
91impl AtomiseToolHandler {
92 #[must_use]
94 pub fn new(atomiser: Arc<Atomiser>, tier: FeatureTier) -> Self {
95 Self { atomiser, tier }
96 }
97}
98
99const REQUIRED_TIER: &str = "smart";
103
104pub fn handle_atomise(
124 conn: &rusqlite::Connection,
125 params: &Value,
126 handler: Option<&AtomiseToolHandler>,
127 tier: FeatureTier,
128 mcp_client: Option<&str>,
129) -> Result<Value, String> {
130 let memory_id = params
132 .get(param_names::MEMORY_ID)
133 .ok_or(crate::errors::msg::MEMORY_ID_REQUIRED)?
134 .as_str()
135 .ok_or("memory_id must be a string")?;
136 if memory_id.is_empty() {
137 return Err("memory_id must not be empty".to_string());
138 }
139
140 let max_atom_tokens: u32 = if let Some(v) = params.get(param_names::MAX_ATOM_TOKENS) {
144 if v.is_null() {
145 crate::atomisation::DEFAULT_ATOM_TOKENS
146 } else {
147 let n = v.as_i64().ok_or_else(|| {
148 format!(
149 "max_atom_tokens must be an integer in [{}, {}] (default {})",
150 crate::atomisation::MIN_ATOM_TOKENS,
151 crate::atomisation::MAX_ATOM_TOKENS,
152 crate::atomisation::DEFAULT_ATOM_TOKENS
153 )
154 })?;
155 if !(i64::from(crate::atomisation::MIN_ATOM_TOKENS)
156 ..=i64::from(crate::atomisation::MAX_ATOM_TOKENS))
157 .contains(&n)
158 {
159 return Err(format!(
160 "max_atom_tokens out of range [{}, {}]: {n}",
161 crate::atomisation::MIN_ATOM_TOKENS,
162 crate::atomisation::MAX_ATOM_TOKENS
163 ));
164 }
165 u32::try_from(n).expect("range-checked above")
166 }
167 } else {
168 crate::atomisation::DEFAULT_ATOM_TOKENS
169 };
170
171 let force_re_atomise: bool = if let Some(v) = params.get(param_names::FORCE_RE_ATOMISE) {
175 if v.is_null() {
176 false
177 } else {
178 v.as_bool().ok_or("force_re_atomise must be a boolean")?
179 }
180 } else {
181 false
182 };
183
184 if tier == FeatureTier::Keyword || handler.is_none() {
190 return Ok(json!({
191 (field_names::TIER_LOCKED): "memory_atomise requires smart tier or higher",
192 (field_names::CURRENT_TIER): tier.as_str(),
193 (field_names::REQUIRED_TIER): REQUIRED_TIER,
194 }));
195 }
196 let handler = handler.expect("checked above");
197
198 let explicit_agent_id = params.get(param_names::AGENT_ID).and_then(Value::as_str);
200 let calling_agent_id = crate::identity::resolve_agent_id(explicit_agent_id, mcp_client)
201 .map_err(|e| e.to_string())?;
202
203 match handler.atomiser.atomise_sync(
205 conn,
206 memory_id,
207 max_atom_tokens,
208 force_re_atomise,
209 &calling_agent_id,
210 ) {
211 Ok(result) => Ok(json!({
212 "source_id": result.source_id,
213 "atom_ids": result.atom_ids,
214 (field_names::ATOM_COUNT): result.atom_count,
215 (field_names::ARCHIVED_AT): result.archived_at,
216 })),
217 Err(AtomiseError::NotFound) => Err(format!("MEMORY_NOT_FOUND: {memory_id}")),
218 Err(AtomiseError::AlreadyAtomised {
219 source_id,
220 existing_atom_ids,
221 }) => Ok(json!({
222 "already_atomised": true,
223 "source_id": source_id,
224 "existing_atom_ids": existing_atom_ids,
225 (field_names::ATOM_COUNT): existing_atom_ids.len(),
226 })),
227 Err(AtomiseError::TierLocked) => Ok(json!({
228 (field_names::TIER_LOCKED): "memory_atomise requires smart tier or higher",
229 (field_names::CURRENT_TIER): tier.as_str(),
230 (field_names::REQUIRED_TIER): REQUIRED_TIER,
231 })),
232 Err(AtomiseError::CuratorFailed(detail)) => Err(format!("CURATOR_FAILED: {detail}")),
233 Err(AtomiseError::SourceTooSmall) => Ok(json!({
234 "source_too_small": true,
235 "source_id": memory_id,
236 "message": "source body is already at or under max_atom_tokens — no decomposition possible",
237 })),
238 Err(AtomiseError::GovernanceRefused(detail)) => {
239 Err(format!("GOVERNANCE_REFUSED: {detail}"))
245 }
246 Err(AtomiseError::SignerError(detail)) => Err(format!("SIGNER_ERROR: {detail}")),
247 Err(AtomiseError::DbError(detail)) => Err(format!("DB_ERROR: {detail}")),
248 Err(AtomiseError::DepthExceeded { attempted, cap }) => Err(format!(
254 "ATOMISATION_DEPTH_EXCEEDED: atomisation depth {attempted} would exceed \
255 compiled max_atomisation_depth {cap}"
256 )),
257 }
258}
259
260use crate::mcp::registry::McpTool;
263use schemars::JsonSchema;
264use serde::Deserialize;
265
266#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
268#[allow(dead_code)]
269pub struct AtomiseRequest {
270 pub memory_id: String,
272
273 #[serde(default)]
275 pub max_atom_tokens: Option<i64>,
276
277 #[serde(default)]
279 pub force_re_atomise: Option<bool>,
280}
281
282#[allow(dead_code)]
284pub struct AtomiseTool;
285
286impl McpTool for AtomiseTool {
287 fn name() -> &'static str {
288 crate::mcp::registry::tool_names::MEMORY_ATOMISE
289 }
290 fn description() -> &'static str {
291 "Decompose a memory into 2-10 atomic propositions; source archived. Smart+ tier."
292 }
293 fn docs() -> &'static str {
294 "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."
295 }
296 fn input_schema() -> Value {
297 crate::mcp::registry::input_schema_for::<AtomiseRequest>()
298 }
299 fn family() -> &'static str {
300 crate::profile::Family::Power.name()
301 }
302}
303
304#[cfg(test)]
305mod d1_5_986_tests {
306 use super::*;
309 use crate::mcp::parity_test_helpers::{
310 assert_descriptions_match, assert_property_set_parity, derived_props_for,
311 };
312
313 #[test]
314 fn atomise_parity_986() {
315 let derived = derived_props_for::<AtomiseRequest>();
316 assert_property_set_parity("memory_atomise", &derived);
317 assert_descriptions_match("memory_atomise", &derived);
318 }
319
320 #[test]
321 fn atomise_tool_metadata_986() {
322 assert_eq!(AtomiseTool::name(), "memory_atomise");
323 assert_eq!(AtomiseTool::family(), "power");
324 }
325}
326
327#[cfg(test)]
335mod tests {
336 use super::*;
337 use crate::atomisation::AtomiserConfig;
338 use crate::atomisation::curator::{Atom, Curator, CuratorError};
339 use crate::storage as db;
340 use std::sync::Mutex;
341 use tempfile::NamedTempFile;
342
343 struct MockCurator {
347 responses: Mutex<Vec<Result<Vec<Atom>, CuratorError>>>,
348 }
349
350 impl MockCurator {
351 fn new(responses: Vec<Result<Vec<Atom>, CuratorError>>) -> Self {
352 Self {
353 responses: Mutex::new(responses),
354 }
355 }
356 }
357
358 impl Curator for MockCurator {
359 fn decompose(
360 &self,
361 _body: &str,
362 _max_atom_tokens: u32,
363 _max_retries: u32,
364 ) -> Result<Vec<Atom>, CuratorError> {
365 let mut q = self.responses.lock().unwrap();
366 if q.is_empty() {
367 return Err(CuratorError::MalformedResponse(
368 "mock: queue exhausted".into(),
369 ));
370 }
371 q.remove(0)
372 }
373 }
374
375 fn fresh_db() -> (NamedTempFile, rusqlite::Connection) {
376 let tmp = NamedTempFile::new().expect("tempfile");
377 let conn = db::open(tmp.path()).expect("db::open");
378 (tmp, conn)
379 }
380
381 fn handler(tier: FeatureTier) -> AtomiseToolHandler {
382 let curator: Box<dyn Curator> = Box::new(MockCurator::new(vec![]));
383 let atomiser = Arc::new(Atomiser::new(
384 curator,
385 None,
386 AtomiserConfig::default(),
387 tier,
388 ));
389 AtomiseToolHandler::new(atomiser, tier)
390 }
391
392 #[test]
393 fn missing_memory_id_errors() {
394 let (_tmp, conn) = fresh_db();
395 let h = handler(FeatureTier::Smart);
396 let err =
397 handle_atomise(&conn, &json!({}), Some(&h), FeatureTier::Smart, None).unwrap_err();
398 assert!(err.contains("memory_id is required"), "got: {err}");
399 }
400
401 #[test]
402 fn non_string_memory_id_errors() {
403 let (_tmp, conn) = fresh_db();
404 let h = handler(FeatureTier::Smart);
405 let err = handle_atomise(
406 &conn,
407 &json!({"memory_id": 42}),
408 Some(&h),
409 FeatureTier::Smart,
410 None,
411 )
412 .unwrap_err();
413 assert!(err.contains("must be a string"), "got: {err}");
414 }
415
416 #[test]
417 fn empty_memory_id_errors() {
418 let (_tmp, conn) = fresh_db();
419 let h = handler(FeatureTier::Smart);
420 let err = handle_atomise(
421 &conn,
422 &json!({"memory_id": ""}),
423 Some(&h),
424 FeatureTier::Smart,
425 None,
426 )
427 .unwrap_err();
428 assert!(err.contains("must not be empty"), "got: {err}");
429 }
430
431 #[test]
432 fn max_atom_tokens_zero_rejected() {
433 let (_tmp, conn) = fresh_db();
434 let h = handler(FeatureTier::Smart);
435 let err = handle_atomise(
436 &conn,
437 &json!({"memory_id": "11111111-2222-3333-4444-555555555555", "max_atom_tokens": 0}),
438 Some(&h),
439 FeatureTier::Smart,
440 None,
441 )
442 .unwrap_err();
443 assert!(err.contains("out of range"), "got: {err}");
444 }
445
446 #[test]
447 fn max_atom_tokens_below_range_rejected() {
448 let (_tmp, conn) = fresh_db();
449 let h = handler(FeatureTier::Smart);
450 let err = handle_atomise(
451 &conn,
452 &json!({"memory_id": "11111111-2222-3333-4444-555555555555", "max_atom_tokens": 49}),
453 Some(&h),
454 FeatureTier::Smart,
455 None,
456 )
457 .unwrap_err();
458 assert!(err.contains("out of range"), "got: {err}");
459 }
460
461 #[test]
462 fn max_atom_tokens_above_range_rejected() {
463 let (_tmp, conn) = fresh_db();
464 let h = handler(FeatureTier::Smart);
465 let err = handle_atomise(
466 &conn,
467 &json!({"memory_id": "11111111-2222-3333-4444-555555555555", "max_atom_tokens": 1001}),
468 Some(&h),
469 FeatureTier::Smart,
470 None,
471 )
472 .unwrap_err();
473 assert!(err.contains("out of range"), "got: {err}");
474 }
475
476 #[test]
477 fn max_atom_tokens_non_int_rejected() {
478 let (_tmp, conn) = fresh_db();
479 let h = handler(FeatureTier::Smart);
480 let err = handle_atomise(
481 &conn,
482 &json!({
483 "memory_id": "11111111-2222-3333-4444-555555555555",
484 "max_atom_tokens": "200"
485 }),
486 Some(&h),
487 FeatureTier::Smart,
488 None,
489 )
490 .unwrap_err();
491 assert!(err.contains("integer"), "got: {err}");
492 }
493
494 #[test]
495 fn force_re_atomise_string_rejected() {
496 let (_tmp, conn) = fresh_db();
497 let h = handler(FeatureTier::Smart);
498 let err = handle_atomise(
499 &conn,
500 &json!({
501 "memory_id": "11111111-2222-3333-4444-555555555555",
502 "force_re_atomise": "yes"
503 }),
504 Some(&h),
505 FeatureTier::Smart,
506 None,
507 )
508 .unwrap_err();
509 assert!(err.contains("boolean"), "got: {err}");
510 }
511
512 #[test]
513 fn keyword_tier_returns_tier_locked_advisory() {
514 let (_tmp, conn) = fresh_db();
515 let resp = handle_atomise(
516 &conn,
517 &json!({"memory_id": "11111111-2222-3333-4444-555555555555"}),
518 None,
519 FeatureTier::Keyword,
520 None,
521 )
522 .expect("tier-locked is informational, not an error");
523 assert_eq!(
524 resp["tier-locked"].as_str(),
525 Some("memory_atomise requires smart tier or higher")
526 );
527 assert_eq!(resp["current_tier"].as_str(), Some("keyword"));
528 assert_eq!(resp["required_tier"].as_str(), Some("smart"));
529 }
530
531 #[test]
532 fn handler_none_at_higher_tier_still_tier_locked() {
533 let (_tmp, conn) = fresh_db();
537 let resp = handle_atomise(
538 &conn,
539 &json!({"memory_id": "11111111-2222-3333-4444-555555555555"}),
540 None,
541 FeatureTier::Semantic,
542 None,
543 )
544 .expect("absence of handler collapses to tier-locked, not error");
545 assert!(resp["tier-locked"].is_string());
546 }
547
548 #[test]
549 fn memory_not_found_returns_typed_error() {
550 let (_tmp, conn) = fresh_db();
551 let h = handler(FeatureTier::Smart);
552 let err = handle_atomise(
554 &conn,
555 &json!({"memory_id": "11111111-2222-3333-4444-555555555555"}),
556 Some(&h),
557 FeatureTier::Smart,
558 None,
559 )
560 .unwrap_err();
561 assert!(err.starts_with("MEMORY_NOT_FOUND:"), "got: {err}");
562 }
563
564 fn handler_with(
574 tier: FeatureTier,
575 responses: Vec<Result<Vec<Atom>, CuratorError>>,
576 ) -> AtomiseToolHandler {
577 let curator: Box<dyn Curator> = Box::new(MockCurator::new(responses));
578 let atomiser = Arc::new(Atomiser::new(
579 curator,
580 None,
581 AtomiserConfig::default(),
582 tier,
583 ));
584 AtomiseToolHandler::new(atomiser, tier)
585 }
586
587 fn seed_big(conn: &rusqlite::Connection, ns: &str) -> String {
591 use crate::models::{Memory, MemoryKind, Tier};
592 let now = chrono::Utc::now().to_rfc3339();
593 let mem = Memory {
594 id: uuid::Uuid::new_v4().to_string(),
595 tier: Tier::Mid,
596 namespace: ns.to_string(),
597 title: format!("src-{}", uuid::Uuid::new_v4().simple()),
598 content: "proposition token padding here. ".repeat(400),
599 created_at: now.clone(),
600 updated_at: now,
601 metadata: serde_json::json!({"agent_id": "ai:test"}),
602 memory_kind: MemoryKind::Observation,
603 ..Default::default()
604 };
605 db::insert(conn, &mem).unwrap()
606 }
607
608 #[test]
609 fn successful_atomise_returns_atom_ids_and_count() {
610 let (_tmp, conn) = fresh_db();
611 let id = seed_big(&conn, "atomise-ok");
612 let h = handler_with(
613 FeatureTier::Smart,
614 vec![Ok(vec![
615 Atom {
616 text: "first proposition".into(),
617 },
618 Atom {
619 text: "second proposition".into(),
620 },
621 ])],
622 );
623 let resp = handle_atomise(
624 &conn,
625 &json!({"memory_id": id, "max_atom_tokens": 50}),
626 Some(&h),
627 FeatureTier::Smart,
628 None,
629 )
630 .expect("atomise ok");
631 assert_eq!(resp["source_id"].as_str(), Some(id.as_str()));
632 assert!(resp["atom_count"].as_u64().unwrap() >= 2);
633 assert!(resp["atom_ids"].as_array().unwrap().len() >= 2);
634 }
635
636 #[test]
637 fn already_atomised_returns_existing_envelope() {
638 let (_tmp, conn) = fresh_db();
639 let id = seed_big(&conn, "atomise-twice");
640 let h = handler_with(
642 FeatureTier::Smart,
643 vec![Ok(vec![
644 Atom {
645 text: "a one".into(),
646 },
647 Atom {
648 text: "a two".into(),
649 },
650 ])],
651 );
652 handle_atomise(
653 &conn,
654 &json!({"memory_id": id, "max_atom_tokens": 50}),
655 Some(&h),
656 FeatureTier::Smart,
657 None,
658 )
659 .expect("first atomise ok");
660 let resp = handle_atomise(
664 &conn,
665 &json!({"memory_id": id, "max_atom_tokens": 50}),
666 Some(&h),
667 FeatureTier::Smart,
668 None,
669 )
670 .expect("already-atomised is informational");
671 assert_eq!(resp["already_atomised"].as_bool(), Some(true));
672 assert_eq!(resp["source_id"].as_str(), Some(id.as_str()));
673 assert!(resp["existing_atom_ids"].as_array().unwrap().len() >= 2);
674 }
675
676 #[test]
677 fn source_too_small_returns_advisory() {
678 let (_tmp, conn) = fresh_db();
679 use crate::models::{Memory, MemoryKind, Tier};
680 let now = chrono::Utc::now().to_rfc3339();
681 let mem = Memory {
682 id: uuid::Uuid::new_v4().to_string(),
683 tier: Tier::Mid,
684 namespace: "tiny".into(),
685 title: "tiny-src".into(),
686 content: "tiny".into(),
687 created_at: now.clone(),
688 updated_at: now,
689 metadata: serde_json::json!({"agent_id": "ai:test"}),
690 memory_kind: MemoryKind::Observation,
691 ..Default::default()
692 };
693 let id = db::insert(&conn, &mem).unwrap();
694 let h = handler_with(FeatureTier::Smart, vec![]);
695 let resp = handle_atomise(
696 &conn,
697 &json!({"memory_id": id, "max_atom_tokens": 200}),
698 Some(&h),
699 FeatureTier::Smart,
700 None,
701 )
702 .expect("source-too-small is informational");
703 assert_eq!(resp["source_too_small"].as_bool(), Some(true));
704 assert_eq!(resp["source_id"].as_str(), Some(id.as_str()));
705 }
706
707 #[test]
708 fn curator_failure_returns_typed_error() {
709 let (_tmp, conn) = fresh_db();
710 let id = seed_big(&conn, "atomise-curfail");
711 let h = handler_with(
712 FeatureTier::Smart,
713 vec![Err(CuratorError::LlmUnavailable("down".into()))],
714 );
715 let err = handle_atomise(
716 &conn,
717 &json!({"memory_id": id, "max_atom_tokens": 50}),
718 Some(&h),
719 FeatureTier::Smart,
720 None,
721 )
722 .unwrap_err();
723 assert!(err.starts_with("CURATOR_FAILED:"), "got: {err}");
724 }
725
726 #[test]
727 fn null_max_atom_tokens_uses_default() {
728 let (_tmp, conn) = fresh_db();
731 let id = seed_big(&conn, "atomise-null");
732 let h = handler_with(
733 FeatureTier::Smart,
734 vec![Ok(vec![
735 Atom {
736 text: "n one".into(),
737 },
738 Atom {
739 text: "n two".into(),
740 },
741 ])],
742 );
743 let resp = handle_atomise(
744 &conn,
745 &json!({"memory_id": id, "max_atom_tokens": null, "force_re_atomise": null}),
746 Some(&h),
747 FeatureTier::Smart,
748 None,
749 )
750 .expect("null tokens defaults cleanly");
751 assert!(resp["atom_count"].as_u64().unwrap() >= 2);
752 }
753}