1pub mod anchored_summary;
61pub mod node;
62pub mod pattern_promotion;
63pub mod query;
64pub mod snapshot;
65pub mod store;
66mod trajectory_persist;
67pub mod trajectory_table;
68
69pub use anchored_summary::{anchored_summary_id, ANCHORED_SUMMARY_TAG};
70
71pub use trajectory_persist::{
72 persist_trajectory_coarse_tools, persist_trajectory_for_episode, trajectory_env_enabled,
73};
74
75pub use node::{
76 AinlEdge, AinlMemoryNode, AinlNodeKind, AinlNodeType, EpisodicNode, FailureNode,
77 MemoryCategory, PersonaLayer, PersonaNode, PersonaSource, ProceduralNode, ProcedureType,
78 RuntimeStateNode, SemanticNode, Sentiment, StrengthEvent, TrajectoryNode,
79};
80pub use query::{
81 count_by_topic_cluster, find_high_confidence_facts, find_patterns, find_strong_traits,
82 recall_by_procedure_type, recall_by_topic_cluster, recall_contradictions,
83 recall_delta_by_relevance, recall_episodes_by_conversation, recall_episodes_with_signal,
84 recall_flagged_episodes, recall_low_success_procedures, recall_recent, recall_strength_history,
85 recall_task_scoped_episodes, walk_from, GraphQuery,
86};
87pub use snapshot::{
88 AgentGraphSnapshot, DanglingEdgeDetail, GraphValidationReport, SnapshotEdge,
89 SNAPSHOT_SCHEMA_VERSION,
90};
91pub use store::{GraphStore, GraphValidationError, SnapshotImportError, SqliteGraphStore};
92pub use trajectory_table::TrajectoryDetailRecord;
93
94use ainl_contracts::{
95 ProcedureArtifact, ProcedureLifecycle, ProcedureReuseOutcome, ProcedureStepKind,
96 TrajectoryOutcome,
97};
98use uuid::Uuid;
99
100pub struct GraphMemory {
104 store: SqliteGraphStore,
105}
106
107fn score_procedure_artifact(
108 artifact: &ProcedureArtifact,
109 intent: &str,
110 available_tools: &[String],
111) -> f32 {
112 let haystack = format!(
113 "{} {} {}",
114 artifact.title.to_ascii_lowercase(),
115 artifact.intent.to_ascii_lowercase(),
116 artifact.summary.to_ascii_lowercase()
117 );
118 let tokens = intent
119 .split(|c: char| !c.is_ascii_alphanumeric())
120 .filter(|token| token.len() >= 3)
121 .map(str::to_ascii_lowercase)
122 .collect::<Vec<_>>();
123 let intent_score = if tokens.is_empty() {
124 0.0
125 } else {
126 tokens
127 .iter()
128 .filter(|token| haystack.contains(token.as_str()))
129 .count() as f32
130 / tokens.len() as f32
131 };
132 let tool_score = if artifact.required_tools.is_empty() {
133 0.2
134 } else {
135 artifact
136 .required_tools
137 .iter()
138 .filter(|tool| available_tools.iter().any(|available| available == *tool))
139 .count() as f32
140 / artifact.required_tools.len() as f32
141 };
142 ((intent_score * 0.55) + (tool_score * 0.30) + (artifact.fitness.clamp(0.0, 1.0) * 0.15))
143 .clamp(0.0, 1.0)
144}
145
146impl GraphMemory {
147 pub fn new(db_path: &std::path::Path) -> Result<Self, String> {
152 let store = SqliteGraphStore::open(db_path)?;
153 Ok(Self { store })
154 }
155
156 pub fn from_connection(conn: rusqlite::Connection) -> Result<Self, String> {
158 let store = SqliteGraphStore::from_connection(conn)?;
159 Ok(Self { store })
160 }
161
162 pub fn from_sqlite_store(store: SqliteGraphStore) -> Self {
164 Self { store }
165 }
166
167 pub fn write_episode(
177 &self,
178 tool_calls: Vec<String>,
179 delegation_to: Option<String>,
180 trace_event: Option<serde_json::Value>,
181 ) -> Result<Uuid, String> {
182 let turn_id = Uuid::new_v4();
183 let timestamp = chrono::Utc::now().timestamp();
184
185 let node =
186 AinlMemoryNode::new_episode(turn_id, timestamp, tool_calls, delegation_to, trace_event);
187
188 let node_id = node.id;
189 self.store.write_node(&node)?;
190 Ok(node_id)
191 }
192
193 pub fn write_fact(
203 &self,
204 fact: String,
205 confidence: f32,
206 source_turn_id: Uuid,
207 ) -> Result<Uuid, String> {
208 let node = AinlMemoryNode::new_fact(fact, confidence, source_turn_id);
209 let node_id = node.id;
210 self.store.write_node(&node)?;
211 Ok(node_id)
212 }
213
214 pub fn store_pattern(
223 &self,
224 pattern_name: String,
225 compiled_graph: Vec<u8>,
226 ) -> Result<Uuid, String> {
227 let node = AinlMemoryNode::new_pattern(pattern_name, compiled_graph);
228 let node_id = node.id;
229 self.store.write_node(&node)?;
230 Ok(node_id)
231 }
232
233 pub fn write_procedural(
239 &self,
240 pattern_name: &str,
241 tool_sequence: Vec<String>,
242 confidence: f32,
243 ) -> Result<Uuid, String> {
244 let mut node = AinlMemoryNode::new_procedural_tools(
245 pattern_name.to_string(),
246 tool_sequence,
247 confidence,
248 );
249 if let AinlNodeType::Procedural { ref mut procedural } = node.node_type {
250 procedural.pattern_observation_count = procedural
251 .pattern_observation_count
252 .max(crate::pattern_promotion::DEFAULT_MIN_OBSERVATIONS);
253 let floor = crate::pattern_promotion::DEFAULT_FITNESS_FLOOR;
254 if let Some(f) = procedural.fitness {
255 procedural.fitness = Some(f.max(floor));
256 } else {
257 procedural.fitness = Some(floor);
258 }
259 procedural.prompt_eligible = true;
260 }
261 let node_id = node.id;
262 self.store.write_node(&node)?;
263 Ok(node_id)
264 }
265
266 pub fn write_procedure_artifact(&self, artifact: &ProcedureArtifact) -> Result<Uuid, String> {
272 self.write_procedure_artifact_for_agent("", artifact)
273 }
274
275 pub fn write_procedure_artifact_for_agent(
277 &self,
278 agent_id: &str,
279 artifact: &ProcedureArtifact,
280 ) -> Result<Uuid, String> {
281 let artifact_json = serde_json::to_vec(artifact).map_err(|e| e.to_string())?;
282 let tool_sequence = artifact
283 .steps
284 .iter()
285 .filter_map(|step| match &step.kind {
286 ProcedureStepKind::ToolCall { tool, .. } => Some(tool.clone()),
287 _ => None,
288 })
289 .collect::<Vec<_>>();
290 let mut node = AinlMemoryNode::new_pattern(artifact.id.clone(), artifact_json);
291 node.agent_id = agent_id.to_string();
292 if let AinlNodeType::Procedural { ref mut procedural } = node.node_type {
293 procedural.tool_sequence = tool_sequence;
294 procedural.confidence = Some(artifact.fitness.clamp(0.0, 1.0));
295 procedural.fitness = Some(artifact.fitness.clamp(0.0, 1.0));
296 procedural.pattern_observation_count = artifact.observation_count;
297 procedural.prompt_eligible = matches!(
298 artifact.lifecycle,
299 ProcedureLifecycle::Validated | ProcedureLifecycle::Promoted
300 );
301 procedural.label = artifact.id.clone();
302 procedural.trigger_conditions = vec![artifact.intent.clone()];
303 }
304 let node_id = node.id;
305 self.store.write_node(&node)?;
306 Ok(node_id)
307 }
308
309 pub fn upsert_procedure_artifact_for_agent(
311 &self,
312 agent_id: &str,
313 artifact: &ProcedureArtifact,
314 ) -> Result<Uuid, String> {
315 for mut node in self.store.find_by_type("procedural")? {
316 if node.agent_id != agent_id {
317 continue;
318 }
319 let Some(procedural) = node.procedural() else {
320 continue;
321 };
322 let matches_id = procedural.label == artifact.id
323 || serde_json::from_slice::<ProcedureArtifact>(&procedural.compiled_graph)
324 .map(|existing| existing.id == artifact.id)
325 .unwrap_or(false);
326 if !matches_id {
327 continue;
328 }
329 let artifact_json = serde_json::to_vec(artifact).map_err(|e| e.to_string())?;
330 let tool_sequence = artifact
331 .steps
332 .iter()
333 .filter_map(|step| match &step.kind {
334 ProcedureStepKind::ToolCall { tool, .. } => Some(tool.clone()),
335 _ => None,
336 })
337 .collect::<Vec<_>>();
338 if let AinlNodeType::Procedural { ref mut procedural } = node.node_type {
339 procedural.compiled_graph = artifact_json;
340 procedural.tool_sequence = tool_sequence;
341 procedural.confidence = Some(artifact.fitness.clamp(0.0, 1.0));
342 procedural.fitness = Some(artifact.fitness.clamp(0.0, 1.0));
343 procedural.pattern_observation_count = artifact.observation_count;
344 procedural.prompt_eligible = matches!(
345 artifact.lifecycle,
346 ProcedureLifecycle::Validated | ProcedureLifecycle::Promoted
347 );
348 procedural.label = artifact.id.clone();
349 procedural.trigger_conditions = vec![artifact.intent.clone()];
350 }
351 let node_id = node.id;
352 self.store.write_node(&node)?;
353 return Ok(node_id);
354 }
355 self.write_procedure_artifact_for_agent(agent_id, artifact)
356 }
357
358 pub fn recall_procedure_artifacts(&self) -> Result<Vec<ProcedureArtifact>, String> {
360 let mut out = Vec::new();
361 for node in self.store.find_by_type("procedural")? {
362 let Some(procedural) = node.procedural() else {
363 continue;
364 };
365 if !procedural.prompt_eligible || procedural.compiled_graph.is_empty() {
366 continue;
367 }
368 if let Ok(artifact) =
369 serde_json::from_slice::<ProcedureArtifact>(&procedural.compiled_graph)
370 {
371 out.push(artifact);
372 }
373 }
374 Ok(out)
375 }
376
377 pub fn search_procedure_artifacts_for_agent(
379 &self,
380 agent_id: &str,
381 intent: &str,
382 available_tools: &[String],
383 limit: usize,
384 ) -> Result<Vec<ProcedureArtifact>, String> {
385 let mut scored = Vec::new();
386 for node in self.store.find_by_type("procedural")? {
387 if node.agent_id != agent_id {
388 continue;
389 }
390 let Some(procedural) = node.procedural() else {
391 continue;
392 };
393 if !procedural.prompt_eligible || procedural.compiled_graph.is_empty() {
394 continue;
395 }
396 let Ok(artifact) =
397 serde_json::from_slice::<ProcedureArtifact>(&procedural.compiled_graph)
398 else {
399 continue;
400 };
401 if matches!(artifact.lifecycle, ProcedureLifecycle::Deprecated) {
402 continue;
403 }
404 let score = score_procedure_artifact(&artifact, intent, available_tools);
405 if score > 0.0 {
406 scored.push((score, artifact));
407 }
408 }
409 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
410 Ok(scored
411 .into_iter()
412 .take(limit)
413 .map(|(_, artifact)| artifact)
414 .collect())
415 }
416
417 pub fn record_procedure_reuse_outcome_for_agent(
419 &self,
420 agent_id: &str,
421 outcome: &ProcedureReuseOutcome,
422 ) -> Result<Uuid, String> {
423 let mut artifacts =
424 self.search_procedure_artifacts_for_agent(agent_id, "", &[], usize::MAX)?;
425 let Some(mut artifact) = artifacts
426 .drain(..)
427 .find(|artifact| artifact.id == outcome.procedure_id)
428 else {
429 return Err(format!(
430 "procedure artifact not found: {}",
431 outcome.procedure_id
432 ));
433 };
434 artifact.observation_count = artifact.observation_count.saturating_add(1);
435 let delta = match outcome.outcome {
436 TrajectoryOutcome::Success => 0.04,
437 TrajectoryOutcome::PartialSuccess => 0.01,
438 TrajectoryOutcome::Failure => -0.08,
439 TrajectoryOutcome::Aborted => -0.12,
440 };
441 artifact.fitness = (artifact.fitness + delta).clamp(0.0, 1.0);
442 if let Some(failure_id) = outcome.failure_id.as_ref() {
443 if !artifact
444 .source_failure_ids
445 .iter()
446 .any(|id| id == failure_id)
447 {
448 artifact.source_failure_ids.push(failure_id.clone());
449 }
450 }
451 self.upsert_procedure_artifact_for_agent(agent_id, &artifact)
452 }
453
454 pub fn write_edge(&self, source: Uuid, target: Uuid, rel: &str) -> Result<(), String> {
456 self.store.insert_graph_edge(source, target, rel)
457 }
458
459 pub fn recall_recent(&self, seconds_ago: i64) -> Result<Vec<AinlMemoryNode>, String> {
467 let since = chrono::Utc::now().timestamp() - seconds_ago;
468 self.store.query_episodes_since(since, 100)
469 }
470
471 pub fn recall_by_type(
473 &self,
474 kind: AinlNodeKind,
475 seconds_ago: i64,
476 ) -> Result<Vec<AinlMemoryNode>, String> {
477 let since = chrono::Utc::now().timestamp() - seconds_ago;
478 self.store
479 .query_nodes_by_type_since(kind.as_str(), since, 500)
480 }
481
482 pub fn find_procedural_by_tool_sequence(
486 &self,
487 agent_id: &str,
488 tool_sequence: &[String],
489 ) -> Result<Option<AinlMemoryNode>, String> {
490 let norm: Vec<String> = tool_sequence.iter().map(|s| s.trim().to_string()).collect();
491 if norm.is_empty() {
492 return Ok(None);
493 }
494 let nodes = self.recall_by_type(AinlNodeKind::Procedural, 60 * 60 * 24 * 365 * 5)?;
495 for n in nodes {
496 if n.agent_id != agent_id {
497 continue;
498 }
499 let AinlNodeType::Procedural { ref procedural } = n.node_type else {
500 continue;
501 };
502 if procedural.tool_sequence.len() != norm.len() {
503 continue;
504 }
505 let same = procedural
506 .tool_sequence
507 .iter()
508 .zip(norm.iter())
509 .all(|(a, b)| a.trim() == b.trim());
510 if same {
511 return Ok(Some(n));
513 }
514 }
515 Ok(None)
516 }
517
518 pub fn write_persona(
520 &self,
521 trait_name: &str,
522 strength: f32,
523 learned_from: Vec<Uuid>,
524 ) -> Result<Uuid, String> {
525 let node = AinlMemoryNode::new_persona(trait_name.to_string(), strength, learned_from);
526 let node_id = node.id;
527 self.store.write_node(&node)?;
528 Ok(node_id)
529 }
530
531 pub fn store(&self) -> &dyn GraphStore {
533 &self.store
534 }
535
536 pub fn sqlite_store(&self) -> &SqliteGraphStore {
538 &self.store
539 }
540
541 pub fn validate_graph(&self, agent_id: &str) -> Result<GraphValidationReport, String> {
543 self.store.validate_graph(agent_id)
544 }
545
546 pub fn export_graph(&self, agent_id: &str) -> Result<AgentGraphSnapshot, String> {
548 self.store.export_graph(agent_id)
549 }
550
551 pub fn import_graph(
553 &mut self,
554 snapshot: &AgentGraphSnapshot,
555 allow_dangling_edges: bool,
556 ) -> Result<(), String> {
557 self.store.import_graph(snapshot, allow_dangling_edges)
558 }
559
560 pub fn agent_subgraph_edges(&self, agent_id: &str) -> Result<Vec<SnapshotEdge>, String> {
562 self.store.agent_subgraph_edges(agent_id)
563 }
564
565 pub fn write_node_with_edges(&mut self, node: &AinlMemoryNode) -> Result<(), String> {
567 self.store.write_node_with_edges(node)
568 }
569
570 pub fn insert_graph_edge_checked(
572 &self,
573 from_id: Uuid,
574 to_id: Uuid,
575 label: &str,
576 ) -> Result<(), String> {
577 self.store.insert_graph_edge_checked(from_id, to_id, label)
578 }
579
580 pub fn read_runtime_state(&self, agent_id: &str) -> Result<Option<RuntimeStateNode>, String> {
582 self.store.read_runtime_state(agent_id)
583 }
584
585 pub fn write_runtime_state(&self, state: &RuntimeStateNode) -> Result<(), String> {
587 self.store.write_runtime_state(state)
588 }
589
590 pub fn write_node(&self, node: &AinlMemoryNode) -> Result<(), String> {
592 self.store.write_node(node)
593 }
594
595 pub fn insert_trajectory_detail(&self, row: &TrajectoryDetailRecord) -> Result<(), String> {
597 self.store.insert_trajectory_detail(row)
598 }
599
600 pub fn list_trajectories_for_agent(
602 &self,
603 agent_id: &str,
604 limit: usize,
605 since_timestamp: Option<i64>,
606 ) -> Result<Vec<TrajectoryDetailRecord>, String> {
607 self.store
608 .list_trajectories_for_agent(agent_id, limit, since_timestamp)
609 }
610
611 pub fn count_trajectory_details_before(
614 &self,
615 agent_id: &str,
616 before_recorded_at: i64,
617 ) -> Result<usize, String> {
618 self.store
619 .count_trajectory_details_before(agent_id, before_recorded_at)
620 }
621
622 pub fn prune_trajectory_details_before(
627 &self,
628 agent_id: &str,
629 before_recorded_at: i64,
630 ) -> Result<usize, String> {
631 self.store
632 .delete_trajectory_details_before(agent_id, before_recorded_at)
633 }
634
635 pub fn search_failures_for_agent(
637 &self,
638 agent_id: &str,
639 query: &str,
640 limit: usize,
641 ) -> Result<Vec<AinlMemoryNode>, String> {
642 self.store
643 .search_failures_fts_for_agent(agent_id, query, limit)
644 }
645
646 pub fn search_all_nodes_fts(
648 &self,
649 agent_id: &str,
650 query: &str,
651 project_id: Option<&str>,
652 limit: usize,
653 ) -> Result<Vec<AinlMemoryNode>, String> {
654 self.store
655 .search_all_nodes_fts_for_agent(agent_id, query, project_id, limit)
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662
663 #[test]
664 fn test_graph_memory_api() {
665 let temp_dir = std::env::temp_dir();
666 let db_path = temp_dir.join("ainl_lib_test.db");
667 let _ = std::fs::remove_file(&db_path);
668
669 let memory = GraphMemory::new(&db_path).expect("Failed to create memory");
670
671 let episode_id = memory
673 .write_episode(
674 vec!["file_read".to_string(), "agent_delegate".to_string()],
675 Some("agent-B".to_string()),
676 None,
677 )
678 .expect("Failed to write episode");
679
680 assert_ne!(episode_id, Uuid::nil());
681
682 let fact_id = memory
684 .write_fact(
685 "User prefers concise responses".to_string(),
686 0.85,
687 episode_id,
688 )
689 .expect("Failed to write fact");
690
691 assert_ne!(fact_id, Uuid::nil());
692
693 let recent = memory.recall_recent(60).expect("Failed to recall");
695 assert_eq!(recent.len(), 1);
696
697 if let AinlNodeType::Episode { episodic } = &recent[0].node_type {
699 assert_eq!(episodic.delegation_to, Some("agent-B".to_string()));
700 assert_eq!(episodic.tool_calls.len(), 2);
701 } else {
702 panic!("Wrong node type");
703 }
704 }
705
706 #[test]
707 fn test_store_pattern() {
708 let temp_dir = std::env::temp_dir();
709 let db_path = temp_dir.join("ainl_lib_test_pattern.db");
710 let _ = std::fs::remove_file(&db_path);
711
712 let memory = GraphMemory::new(&db_path).expect("Failed to create memory");
713
714 let pattern_id = memory
715 .store_pattern("research_workflow".to_string(), vec![1, 2, 3, 4])
716 .expect("Failed to store pattern");
717
718 assert_ne!(pattern_id, Uuid::nil());
719
720 let patterns = find_patterns(memory.store(), "research").expect("Query failed");
722 assert_eq!(patterns.len(), 1);
723 }
724
725 #[test]
727 fn failure_write_and_fts_search_roundtrip() {
728 let dir = tempfile::tempdir().expect("tempdir");
729 let db_path = dir.path().join("ainl_failure_fts_smoke.db");
730 let memory = GraphMemory::new(&db_path).expect("graph memory");
731 let agent_id = "agent-smoke-fts";
732
733 let mut node = AinlMemoryNode::new_loop_guard_failure(
734 "block",
735 Some("shell_exec"),
736 "repeated identical tool invocation blocked by loop guard",
737 Some("session-xyz"),
738 );
739 node.agent_id = agent_id.to_string();
740 let nid = node.id;
741 memory.write_node(&node).expect("write failure node");
742
743 let hits = memory
744 .search_failures_for_agent(agent_id, "loop", 10)
745 .expect("search loop");
746 assert_eq!(hits.len(), 1, "expected one FTS hit for token 'loop'");
747 assert_eq!(hits[0].id, nid);
748 assert!(
749 matches!(&hits[0].node_type, AinlNodeType::Failure { .. }),
750 "expected Failure node type"
751 );
752
753 let hits2 = memory
754 .search_failures_for_agent(agent_id, "shell_exec", 10)
755 .expect("search tool name");
756 assert_eq!(hits2.len(), 1);
757 assert_eq!(hits2[0].id, nid);
758
759 let empty = memory
760 .search_failures_for_agent(agent_id, " ", 10)
761 .expect("whitespace-only query");
762 assert!(empty.is_empty());
763
764 let wrong_agent = memory
765 .search_failures_for_agent("other-agent", "loop", 10)
766 .expect("wrong agent id");
767 assert!(wrong_agent.is_empty());
768 }
769
770 #[test]
772 fn all_nodes_fts_write_and_search_roundtrip() {
773 let dir = tempfile::tempdir().expect("tempdir");
774 let db_path = dir.path().join("ainl_all_nodes_fts.db");
775 let memory = GraphMemory::new(&db_path).expect("graph memory");
776 let agent_id = "agent-fts-all";
777 let mut node =
778 AinlMemoryNode::new_fact("unique-fts-violet-cat-42".into(), 0.8, Uuid::new_v4());
779 node.agent_id = agent_id.to_string();
780 let nid = node.id;
781 memory.write_node(&node).expect("write fact");
782
783 let hits = memory
784 .search_all_nodes_fts(agent_id, "violet", None, 10)
785 .expect("search");
786 assert_eq!(hits.len(), 1, "expected one all-nodes FTS hit");
787 assert_eq!(hits[0].id, nid);
788 }
789
790 #[test]
791 fn tool_execution_failure_write_and_fts_search_roundtrip() {
792 let dir = tempfile::tempdir().expect("tempdir");
793 let db_path = dir.path().join("ainl_tool_failure_fts.db");
794 let memory = GraphMemory::new(&db_path).expect("graph memory");
795 let agent_id = "agent-tool-ft";
796
797 let mut node = AinlMemoryNode::new_tool_execution_failure(
798 "file_read",
799 "ENOENT: no such file or directory",
800 Some("sess-tool-1"),
801 );
802 node.agent_id = agent_id.to_string();
803 let nid = node.id;
804 memory.write_node(&node).expect("write tool failure node");
805
806 let hits = memory
807 .search_failures_for_agent(agent_id, "ENOENT", 10)
808 .expect("search ENOENT");
809 assert_eq!(hits.len(), 1);
810 assert_eq!(hits[0].id, nid);
811
812 let src_hits = memory
813 .search_failures_for_agent(agent_id, "tool_runner", 10)
814 .expect("search source");
815 assert_eq!(src_hits.len(), 1);
816 assert_eq!(src_hits[0].id, nid);
817 }
818
819 #[test]
820 fn agent_loop_precheck_failure_write_and_fts_search_roundtrip() {
821 let dir = tempfile::tempdir().expect("tempdir");
822 let db_path = dir.path().join("ainl_precheck_failure_fts.db");
823 let memory = GraphMemory::new(&db_path).expect("graph memory");
824 let agent_id = "agent-precheck-ft";
825
826 let mut node = AinlMemoryNode::new_agent_loop_precheck_failure(
827 "param_validation",
828 "file_write",
829 "missing required field: path",
830 Some("sess-pv-1"),
831 );
832 node.agent_id = agent_id.to_string();
833 let nid = node.id;
834 memory.write_node(&node).expect("write precheck failure");
835
836 let hits = memory
837 .search_failures_for_agent(agent_id, "param_validation", 10)
838 .expect("search kind");
839 assert_eq!(hits.len(), 1);
840 assert_eq!(hits[0].id, nid);
841
842 let hits2 = memory
843 .search_failures_for_agent(agent_id, "agent_loop", 10)
844 .expect("search agent_loop prefix");
845 assert_eq!(hits2.len(), 1);
846 }
847
848 #[test]
849 fn ainl_runtime_graph_validation_failure_write_and_fts_search_roundtrip() {
850 let dir = tempfile::tempdir().expect("tempdir");
851 let db_path = dir.path().join("ainl_graph_validation_failure_fts.db");
852 let memory = GraphMemory::new(&db_path).expect("graph memory");
853 let agent_id = "agent-graph-val-ft";
854
855 let mut node = AinlMemoryNode::new_ainl_runtime_graph_validation_failure(
856 "graph validation failed before turn: dangling edges …",
857 Some("sess-gv-1"),
858 );
859 node.agent_id = agent_id.to_string();
860 let nid = node.id;
861 memory
862 .write_node(&node)
863 .expect("write graph validation failure");
864
865 let hits = memory
866 .search_failures_for_agent(agent_id, "graph_validation", 10)
867 .expect("search source label");
868 assert_eq!(hits.len(), 1);
869 assert_eq!(hits[0].id, nid);
870
871 let hits2 = memory
872 .search_failures_for_agent(agent_id, "dangling", 10)
873 .expect("search message body");
874 assert_eq!(hits2.len(), 1);
875 }
876
877 #[test]
878 fn trajectory_detail_prune_before_drops_only_old_rows() {
879 use ainl_contracts::{TrajectoryOutcome, TrajectoryStep};
880
881 let dir = tempfile::tempdir().expect("tempdir");
882 let db_path = dir.path().join("ainl_traj_prune.db");
883 let memory = GraphMemory::new(&db_path).expect("graph memory");
884 let agent = "agent-traj-prune";
885 let ep_old = memory
886 .write_episode(vec![], None, None)
887 .expect("episode for old traj");
888 let ep_new = memory
889 .write_episode(vec![], None, None)
890 .expect("episode for new traj");
891 let mk_step = |sid: &str| TrajectoryStep {
892 step_id: sid.to_string(),
893 timestamp_ms: 0,
894 adapter: "a".into(),
895 operation: "o".into(),
896 inputs_preview: None,
897 outputs_preview: None,
898 duration_ms: 1,
899 success: true,
900 error: None,
901 vitals: None,
902 freshness_at_step: None,
903 frame_vars: None,
904 tool_telemetry: None,
905 };
906 let r_old = TrajectoryDetailRecord {
907 id: Uuid::new_v4(),
908 episode_id: ep_old,
909 graph_trajectory_node_id: None,
910 agent_id: agent.to_string(),
911 session_id: "s-old".into(),
912 project_id: None,
913 recorded_at: 100,
914 outcome: TrajectoryOutcome::Success,
915 ainl_source_hash: None,
916 duration_ms: 1,
917 steps: vec![mk_step("1")],
918 frame_vars: None,
919 fitness_delta: None,
920 };
921 let r_new = TrajectoryDetailRecord {
922 id: Uuid::new_v4(),
923 episode_id: ep_new,
924 graph_trajectory_node_id: None,
925 agent_id: agent.to_string(),
926 session_id: "s-new".into(),
927 project_id: None,
928 recorded_at: 200,
929 outcome: TrajectoryOutcome::Success,
930 ainl_source_hash: None,
931 duration_ms: 1,
932 steps: vec![mk_step("2")],
933 frame_vars: None,
934 fitness_delta: None,
935 };
936 memory.insert_trajectory_detail(&r_old).expect("insert old");
937 memory.insert_trajectory_detail(&r_new).expect("insert new");
938 let before = memory
939 .list_trajectories_for_agent(agent, 10, None)
940 .expect("list");
941 assert_eq!(before.len(), 2);
942 let removed = memory
943 .prune_trajectory_details_before(agent, 200)
944 .expect("prune");
945 assert_eq!(removed, 1);
946 let after = memory
947 .list_trajectories_for_agent(agent, 10, None)
948 .expect("list after");
949 assert_eq!(after.len(), 1);
950 assert_eq!(after[0].recorded_at, 200);
951 }
952
953 #[test]
954 fn stores_and_recalls_validated_procedure_artifact() {
955 use ainl_contracts::{
956 ProcedureArtifact, ProcedureArtifactFormat, ProcedureLifecycle, ProcedureStep,
957 ProcedureStepKind, ProcedureVerification, LEARNER_SCHEMA_VERSION,
958 };
959
960 let tmp = tempfile::tempdir().unwrap();
961 let memory = GraphMemory::new(&tmp.path().join("memory.db")).unwrap();
962 let artifact = ProcedureArtifact {
963 schema_version: LEARNER_SCHEMA_VERSION,
964 id: "proc:test".into(),
965 title: "Test Procedure".into(),
966 intent: "test intent".into(),
967 summary: "summary".into(),
968 required_tools: vec!["file_read".into()],
969 required_adapters: vec![],
970 inputs: vec![],
971 outputs: vec![],
972 preconditions: vec![],
973 steps: vec![ProcedureStep {
974 step_id: "s1".into(),
975 title: "Read".into(),
976 kind: ProcedureStepKind::ToolCall {
977 tool: "file_read".into(),
978 args_schema: serde_json::json!({"type":"object"}),
979 },
980 rationale: None,
981 }],
982 verification: ProcedureVerification::default(),
983 known_failures: vec![],
984 recovery: vec![],
985 source_trajectory_ids: vec![],
986 source_failure_ids: vec![],
987 fitness: 0.9,
988 observation_count: 3,
989 lifecycle: ProcedureLifecycle::Validated,
990 render_targets: vec![ProcedureArtifactFormat::PromptOnly],
991 };
992 memory.write_procedure_artifact(&artifact).unwrap();
993 let recalled = memory.recall_procedure_artifacts().unwrap();
994 assert_eq!(recalled, vec![artifact]);
995 }
996
997 #[test]
998 fn searches_and_updates_procedure_reuse_fitness() {
999 use ainl_contracts::{
1000 ProcedureArtifact, ProcedureArtifactFormat, ProcedureLifecycle, ProcedureReuseOutcome,
1001 ProcedureStep, ProcedureStepKind, ProcedureVerification, TrajectoryOutcome,
1002 LEARNER_SCHEMA_VERSION,
1003 };
1004
1005 let tmp = tempfile::tempdir().unwrap();
1006 let memory = GraphMemory::new(&tmp.path().join("memory.db")).unwrap();
1007 let artifact = ProcedureArtifact {
1008 schema_version: LEARNER_SCHEMA_VERSION,
1009 id: "proc:review".into(),
1010 title: "Review PR".into(),
1011 intent: "review pull request".into(),
1012 summary: "review code changes safely".into(),
1013 required_tools: vec!["file_read".into(), "shell_exec".into()],
1014 required_adapters: vec![],
1015 inputs: vec![],
1016 outputs: vec![],
1017 preconditions: vec![],
1018 steps: vec![ProcedureStep {
1019 step_id: "s1".into(),
1020 title: "Read".into(),
1021 kind: ProcedureStepKind::ToolCall {
1022 tool: "file_read".into(),
1023 args_schema: serde_json::json!({"type":"object"}),
1024 },
1025 rationale: None,
1026 }],
1027 verification: ProcedureVerification::default(),
1028 known_failures: vec![],
1029 recovery: vec![],
1030 source_trajectory_ids: vec![],
1031 source_failure_ids: vec![],
1032 fitness: 0.6,
1033 observation_count: 3,
1034 lifecycle: ProcedureLifecycle::Promoted,
1035 render_targets: vec![ProcedureArtifactFormat::PromptOnly],
1036 };
1037 memory
1038 .write_procedure_artifact_for_agent("agent-search", &artifact)
1039 .unwrap();
1040 let hits = memory
1041 .search_procedure_artifacts_for_agent(
1042 "agent-search",
1043 "please review this pull request",
1044 &["file_read".into(), "shell_exec".into()],
1045 5,
1046 )
1047 .unwrap();
1048 assert_eq!(hits.len(), 1);
1049 assert_eq!(hits[0].id, "proc:review");
1050
1051 memory
1052 .record_procedure_reuse_outcome_for_agent(
1053 "agent-search",
1054 &ProcedureReuseOutcome {
1055 procedure_id: "proc:review".into(),
1056 outcome: TrajectoryOutcome::Failure,
1057 failure_id: Some("failure-x".into()),
1058 notes: None,
1059 },
1060 )
1061 .unwrap();
1062 let updated = memory
1063 .search_procedure_artifacts_for_agent("agent-search", "review pull request", &[], 5)
1064 .unwrap();
1065 assert_eq!(updated[0].observation_count, 4);
1066 assert!(updated[0].fitness < 0.6);
1067 assert!(updated[0].source_failure_ids.contains(&"failure-x".into()));
1068 }
1069}