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