Skip to main content

ainl_runtime/
lib.rs

1//! **ainl-runtime** v0.3.5-alpha — orchestration layer for the unified AINL graph (memory substrate + extraction).
2//!
3//! This crate **does not** call LLMs, parse AINL IR, or implement tool adapters. It coordinates
4//! [`ainl_memory`], persona axis state via [`ainl_persona::EvolutionEngine`] (shared with
5//! [`ainl_graph_extractor::GraphExtractorTask`]), and scheduled graph extraction — with [`TurnHooks`] for host
6//! integration (e.g. OpenFang).
7//!
8//! **Evolution:** [`EvolutionEngine`] lives in **ainl-persona**. [`AinlRuntime::evolution_engine_mut`] and
9//! helpers ([`AinlRuntime::apply_evolution_signals`], [`AinlRuntime::persist_evolution_snapshot`], …) drive it
10//! without going through the extractor. [`GraphExtractorTask::run_pass`] remains one signal producer (graph
11//! extract + recurrence + pattern heuristics), not the only way to evolve persona axes.
12//!
13//! For a minimal “record episodes + run extractor” path without the full engine, see [`RuntimeContext`].
14//!
15//! ## Semantic ranking / [`MemoryContext`]
16//!
17//! **`compile_memory_context_for(None)`** no longer inherits previous episode text for semantic
18//! ranking; pass **`Some(user_message)`** if you want topic-aware [`MemoryContext::relevant_semantic`].
19//! [`AinlRuntime::compile_memory_context`] still calls `compile_memory_context_for(None)` (empty
20//! message → high-recurrence fallback). [`AinlRuntime::run_turn`] always passes the current turn text.
21//!
22//! **Async / Tokio:** enable the optional **`async`** crate feature for `AinlRuntime::run_turn_async`.
23//! Graph memory is then `Arc<std::sync::Mutex<GraphMemory>>` (not `tokio::sync::Mutex`) so
24//! [`AinlRuntime::new`] and [`AinlRuntime::sqlite_store`] can take short locks on any thread; SQLite
25//! work for async turns is still offloaded with `tokio::task::spawn_blocking`. See the crate
26//! `README.md` for rationale.
27
28mod adapters;
29mod engine;
30mod graph_cell;
31mod hooks;
32mod runtime;
33
34pub use adapters::{AdapterRegistry, GraphPatchAdapter, GraphPatchHostDispatch, PatchAdapter};
35
36pub use ainl_semantic_tagger::infer_topic_tags;
37
38pub use ainl_graph_extractor::{run_extraction_pass, ExtractionReport, GraphExtractorTask};
39pub use ainl_persona::axes::default_axis_map;
40pub use ainl_persona::{
41    EvolutionEngine, MemoryNodeType, PersonaAxis, PersonaSnapshot, RawSignal, EVOLUTION_TRAIT_NAME,
42    INGEST_SCORE_EPSILON,
43};
44
45pub use engine::{
46    AinlGraphArtifact, AinlRuntimeError, MemoryContext, PatchDispatchContext, PatchDispatchResult,
47    PatchSkipReason, TurnInput, TurnOutcome, TurnPhase, TurnResult, TurnStatus, TurnWarning,
48    EMIT_TO_EDGE,
49};
50pub use graph_cell::SqliteStoreRef;
51pub use hooks::{NoOpHooks, TurnHooks};
52#[cfg(feature = "async")]
53pub use hooks::{NoOpAsyncHooks, TurnHooksAsync};
54pub use runtime::AinlRuntime;
55
56use ainl_memory::{GraphMemory, GraphStore};
57use serde::{Deserialize, Serialize};
58
59/// Configuration for [`AinlRuntime`] and [`RuntimeContext`].
60#[derive(Serialize, Deserialize, Debug, Clone)]
61#[serde(default)]
62pub struct RuntimeConfig {
63    /// Owning agent id (required for extraction, graph queries, and [`AinlRuntime`]).
64    pub agent_id: String,
65    /// Maximum nested [`AinlRuntime::run_turn`] depth (internal counter); see [`TurnInput::depth`].
66    pub max_delegation_depth: u32,
67    pub enable_graph_memory: bool,
68    /// Cap for the minimal BFS graph walk in [`AinlRuntime::run_turn`].
69    pub max_steps: u32,
70    /// Run [`GraphExtractorTask::run_pass`] every N completed turns (`0` disables scheduled passes).
71    pub extraction_interval: u32,
72}
73
74impl Default for RuntimeConfig {
75    fn default() -> Self {
76        Self {
77            agent_id: String::new(),
78            max_delegation_depth: 8,
79            enable_graph_memory: true,
80            max_steps: 1000,
81            extraction_interval: 10,
82        }
83    }
84}
85
86/// Host context: optional memory plus optional stateful extractor (legacy / lightweight).
87pub struct RuntimeContext {
88    _config: RuntimeConfig,
89    memory: Option<GraphMemory>,
90    extractor: Option<GraphExtractorTask>,
91}
92
93impl RuntimeContext {
94    /// Create a new runtime context with the given memory backend.
95    pub fn new(config: RuntimeConfig, memory: Option<GraphMemory>) -> Self {
96        Self {
97            _config: config,
98            memory,
99            extractor: None,
100        }
101    }
102
103    /// Record an agent delegation as an episode node.
104    pub fn record_delegation(
105        &self,
106        delegated_to: String,
107        trace_event: Option<serde_json::Value>,
108    ) -> Result<uuid::Uuid, String> {
109        if let Some(ref memory) = self.memory {
110            memory.write_episode(
111                vec!["agent_delegate".to_string()],
112                Some(delegated_to),
113                trace_event,
114            )
115        } else {
116            Err("Memory not initialized".to_string())
117        }
118    }
119
120    /// Record a tool execution as an episode node.
121    pub fn record_tool_execution(
122        &self,
123        tool_name: String,
124        trace_event: Option<serde_json::Value>,
125    ) -> Result<uuid::Uuid, String> {
126        if let Some(ref memory) = self.memory {
127            memory.write_episode(vec![tool_name], None, trace_event)
128        } else {
129            Err("Memory not initialized".to_string())
130        }
131    }
132
133    /// Record a turn as an episode with an explicit tool-call list.
134    pub fn record_episode(
135        &self,
136        tool_calls: Vec<String>,
137        delegation_to: Option<String>,
138        trace_event: Option<serde_json::Value>,
139    ) -> Result<uuid::Uuid, String> {
140        if let Some(ref memory) = self.memory {
141            memory.write_episode(tool_calls, delegation_to, trace_event)
142        } else {
143            Err("Memory not initialized".to_string())
144        }
145    }
146
147    /// Get direct access to the underlying store for advanced queries.
148    pub fn store(&self) -> Option<&dyn GraphStore> {
149        self.memory.as_ref().map(|m| m.store())
150    }
151
152    /// Run `ainl-graph-extractor` on the backing SQLite store.
153    pub fn run_graph_extraction_pass(&mut self) -> Result<ExtractionReport, String> {
154        if self._config.agent_id.is_empty() {
155            return Err("RuntimeConfig.agent_id is required for graph extraction".to_string());
156        }
157        let memory = self
158            .memory
159            .as_ref()
160            .ok_or_else(|| "Graph memory is required for graph extraction".to_string())?;
161
162        self.extractor
163            .get_or_insert_with(|| GraphExtractorTask::new(&self._config.agent_id));
164        let store = memory.sqlite_store();
165        let report = self
166            .extractor
167            .as_mut()
168            .expect("get_or_insert_with always leaves Some")
169            .run_pass(store);
170
171        if report.has_errors() {
172            tracing::warn!(
173                agent_id = %report.agent_id,
174                extract_error = ?report.extract_error,
175                pattern_error = ?report.pattern_error,
176                persona_error = ?report.persona_error,
177                "ainl-graph-extractor pass completed with phase errors"
178            );
179        } else {
180            tracing::info!(
181                agent_id = %report.agent_id,
182                signals_extracted = report.signals_extracted,
183                signals_applied = report.signals_applied,
184                semantic_nodes_updated = report.semantic_nodes_updated,
185                "ainl-graph-extractor pass completed"
186            );
187        }
188        Ok(report)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::TurnStatus;
196    use ainl_memory::{AinlMemoryNode, SqliteGraphStore};
197    use std::path::PathBuf;
198    use uuid::Uuid;
199
200    #[test]
201    fn ainl_runtime_error_helpers() {
202        let e = AinlRuntimeError::DelegationDepthExceeded { depth: 2, max: 8 };
203        assert!(e.is_delegation_depth_exceeded());
204        assert_eq!(e.delegation_depth_exceeded(), Some((2, 8)));
205        assert!(e.message_str().is_none());
206
207        let m = AinlRuntimeError::Message("graph validation failed".into());
208        assert!(!m.is_delegation_depth_exceeded());
209        assert!(m.delegation_depth_exceeded().is_none());
210        assert_eq!(m.message_str(), Some("graph validation failed"));
211
212        let from_str: AinlRuntimeError = "via from".to_string().into();
213        assert_eq!(from_str.message_str(), Some("via from"));
214    }
215
216    #[test]
217    fn test_runtime_config_default() {
218        let config = RuntimeConfig::default();
219        assert_eq!(config.max_delegation_depth, 8);
220        assert!(config.enable_graph_memory);
221        assert!(config.agent_id.is_empty());
222        assert_eq!(config.max_steps, 1000);
223        assert_eq!(config.extraction_interval, 10);
224    }
225
226    #[test]
227    fn extraction_pass_requires_agent_id() {
228        let dir = tempfile::tempdir().unwrap();
229        let db = dir.path().join("t.db");
230        let mem = GraphMemory::new(&db).unwrap();
231        let mut ctx = RuntimeContext::new(RuntimeConfig::default(), Some(mem));
232        let err = ctx.run_graph_extraction_pass().unwrap_err();
233        assert!(err.contains("agent_id"));
234    }
235
236    #[test]
237    fn extraction_pass_runs_with_memory_and_agent() {
238        let dir = tempfile::tempdir().unwrap();
239        let db: PathBuf = dir.path().join("t.db");
240        let mem = GraphMemory::new(&db).unwrap();
241        let cfg = RuntimeConfig {
242            agent_id: "agent-test".into(),
243            ..RuntimeConfig::default()
244        };
245        let mut ctx = RuntimeContext::new(cfg, Some(mem));
246        ctx.record_tool_execution("noop".into(), None).unwrap();
247        let report = ctx.run_graph_extraction_pass().expect("extraction");
248        assert_eq!(report.agent_id, "agent-test");
249    }
250
251    #[test]
252    fn ainl_runtime_run_turn_smoke() {
253        let dir = tempfile::tempdir().unwrap();
254        let db = dir.path().join("rt.db");
255        let _ = std::fs::remove_file(&db);
256        let store = SqliteGraphStore::open(&db).unwrap();
257        let ag = "rt-agent";
258        let mut ep = AinlMemoryNode::new_episode(Uuid::new_v4(), 3_000_000_000, vec![], None, None);
259        ep.agent_id = ag.into();
260        store.write_node(&ep).unwrap();
261
262        let cfg = RuntimeConfig {
263            agent_id: ag.into(),
264            extraction_interval: 1,
265            max_steps: 50,
266            ..RuntimeConfig::default()
267        };
268        let mut rt = AinlRuntime::new(cfg, store);
269        let art = rt.load_artifact().expect("load");
270        assert!(art.validation.is_valid);
271
272        let out = rt
273            .run_turn(TurnInput {
274                user_message: "hello".into(),
275                tools_invoked: vec!["noop".into()],
276                trace_event: None,
277                depth: 0,
278                ..Default::default()
279            })
280            .expect("turn");
281        assert!(out.is_complete());
282        assert_eq!(out.turn_status(), TurnStatus::Ok);
283        assert_ne!(out.result().episode_id, Uuid::nil());
284        assert!(out.result().extraction_report.is_some());
285    }
286}