Skip to main content

ainl_runtime/
lib.rs

1//! **ainl-runtime** v0.2 — 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
15mod adapters;
16mod engine;
17mod hooks;
18mod runtime;
19
20pub use adapters::{AdapterRegistry, PatchAdapter};
21
22pub use ainl_graph_extractor::{run_extraction_pass, ExtractionReport, GraphExtractorTask};
23pub use ainl_persona::axes::default_axis_map;
24pub use ainl_persona::{
25    EvolutionEngine, MemoryNodeType, PersonaAxis, PersonaSnapshot, RawSignal, EVOLUTION_TRAIT_NAME,
26    INGEST_SCORE_EPSILON,
27};
28
29pub use engine::{
30    AinlGraphArtifact, MemoryContext, PatchDispatchResult, PatchSkipReason, TurnInput, TurnOutcome,
31    TurnOutput, EMIT_TO_EDGE,
32};
33pub use hooks::{NoOpHooks, TurnHooks};
34pub use runtime::AinlRuntime;
35
36use ainl_memory::{GraphMemory, GraphStore};
37use serde::{Deserialize, Serialize};
38
39/// Configuration for [`AinlRuntime`] and [`RuntimeContext`].
40#[derive(Serialize, Deserialize, Debug, Clone)]
41#[serde(default)]
42pub struct RuntimeConfig {
43    /// Owning agent id (required for extraction, graph queries, and [`AinlRuntime`]).
44    pub agent_id: String,
45    /// Maximum nested [`AinlRuntime::run_turn`] depth (internal `delegation_depth`); see [`TurnInput::depth`].
46    pub max_delegation_depth: u32,
47    pub enable_graph_memory: bool,
48    /// Cap for the minimal BFS graph walk in [`AinlRuntime::run_turn`].
49    pub max_steps: u32,
50    /// Run [`GraphExtractorTask::run_pass`] every N completed turns (`0` disables scheduled passes).
51    pub extraction_interval: u32,
52}
53
54impl Default for RuntimeConfig {
55    fn default() -> Self {
56        Self {
57            agent_id: String::new(),
58            max_delegation_depth: 10,
59            enable_graph_memory: true,
60            max_steps: 1000,
61            extraction_interval: 10,
62        }
63    }
64}
65
66/// Host context: optional memory plus optional stateful extractor (legacy / lightweight).
67pub struct RuntimeContext {
68    _config: RuntimeConfig,
69    memory: Option<GraphMemory>,
70    extractor: Option<GraphExtractorTask>,
71}
72
73impl RuntimeContext {
74    /// Create a new runtime context with the given memory backend.
75    pub fn new(config: RuntimeConfig, memory: Option<GraphMemory>) -> Self {
76        Self {
77            _config: config,
78            memory,
79            extractor: None,
80        }
81    }
82
83    /// Record an agent delegation as an episode node.
84    pub fn record_delegation(
85        &self,
86        delegated_to: String,
87        trace_event: Option<serde_json::Value>,
88    ) -> Result<uuid::Uuid, String> {
89        if let Some(ref memory) = self.memory {
90            memory.write_episode(
91                vec!["agent_delegate".to_string()],
92                Some(delegated_to),
93                trace_event,
94            )
95        } else {
96            Err("Memory not initialized".to_string())
97        }
98    }
99
100    /// Record a tool execution as an episode node.
101    pub fn record_tool_execution(
102        &self,
103        tool_name: String,
104        trace_event: Option<serde_json::Value>,
105    ) -> Result<uuid::Uuid, String> {
106        if let Some(ref memory) = self.memory {
107            memory.write_episode(vec![tool_name], None, trace_event)
108        } else {
109            Err("Memory not initialized".to_string())
110        }
111    }
112
113    /// Record a turn as an episode with an explicit tool-call list.
114    pub fn record_episode(
115        &self,
116        tool_calls: Vec<String>,
117        delegation_to: Option<String>,
118        trace_event: Option<serde_json::Value>,
119    ) -> Result<uuid::Uuid, String> {
120        if let Some(ref memory) = self.memory {
121            memory.write_episode(tool_calls, delegation_to, trace_event)
122        } else {
123            Err("Memory not initialized".to_string())
124        }
125    }
126
127    /// Get direct access to the underlying store for advanced queries.
128    pub fn store(&self) -> Option<&dyn GraphStore> {
129        self.memory.as_ref().map(|m| m.store())
130    }
131
132    /// Run `ainl-graph-extractor` on the backing SQLite store.
133    pub fn run_graph_extraction_pass(&mut self) -> Result<ExtractionReport, String> {
134        if self._config.agent_id.is_empty() {
135            return Err("RuntimeConfig.agent_id is required for graph extraction".to_string());
136        }
137        let memory = self
138            .memory
139            .as_ref()
140            .ok_or_else(|| "Graph memory is required for graph extraction".to_string())?;
141
142        self.extractor
143            .get_or_insert_with(|| GraphExtractorTask::new(&self._config.agent_id));
144        let store = memory.sqlite_store();
145        let report = self
146            .extractor
147            .as_mut()
148            .expect("get_or_insert_with always leaves Some")
149            .run_pass(store)?;
150
151        tracing::info!(
152            agent_id = %report.agent_id,
153            signals_extracted = report.signals_extracted,
154            signals_applied = report.signals_applied,
155            semantic_nodes_updated = report.semantic_nodes_updated,
156            "ainl-graph-extractor pass completed"
157        );
158        Ok(report)
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use ainl_memory::{AinlMemoryNode, SqliteGraphStore};
166    use std::path::PathBuf;
167    use uuid::Uuid;
168
169    #[test]
170    fn test_runtime_config_default() {
171        let config = RuntimeConfig::default();
172        assert_eq!(config.max_delegation_depth, 10);
173        assert!(config.enable_graph_memory);
174        assert!(config.agent_id.is_empty());
175        assert_eq!(config.max_steps, 1000);
176        assert_eq!(config.extraction_interval, 10);
177    }
178
179    #[test]
180    fn extraction_pass_requires_agent_id() {
181        let dir = tempfile::tempdir().unwrap();
182        let db = dir.path().join("t.db");
183        let mem = GraphMemory::new(&db).unwrap();
184        let mut ctx = RuntimeContext::new(RuntimeConfig::default(), Some(mem));
185        let err = ctx.run_graph_extraction_pass().unwrap_err();
186        assert!(err.contains("agent_id"));
187    }
188
189    #[test]
190    fn extraction_pass_runs_with_memory_and_agent() {
191        let dir = tempfile::tempdir().unwrap();
192        let db: PathBuf = dir.path().join("t.db");
193        let mem = GraphMemory::new(&db).unwrap();
194        let cfg = RuntimeConfig {
195            agent_id: "agent-test".into(),
196            ..RuntimeConfig::default()
197        };
198        let mut ctx = RuntimeContext::new(cfg, Some(mem));
199        ctx.record_tool_execution("noop".into(), None).unwrap();
200        let report = ctx.run_graph_extraction_pass().expect("extraction");
201        assert_eq!(report.agent_id, "agent-test");
202    }
203
204    #[test]
205    fn ainl_runtime_run_turn_smoke() {
206        let dir = tempfile::tempdir().unwrap();
207        let db = dir.path().join("rt.db");
208        let _ = std::fs::remove_file(&db);
209        let store = SqliteGraphStore::open(&db).unwrap();
210        let ag = "rt-agent";
211        let mut ep = AinlMemoryNode::new_episode(Uuid::new_v4(), 3_000_000_000, vec![], None, None);
212        ep.agent_id = ag.into();
213        store.write_node(&ep).unwrap();
214
215        let cfg = RuntimeConfig {
216            agent_id: ag.into(),
217            extraction_interval: 1,
218            max_steps: 50,
219            ..RuntimeConfig::default()
220        };
221        let mut rt = AinlRuntime::new(cfg, store);
222        let art = rt.load_artifact().expect("load");
223        assert!(art.validation.is_valid);
224
225        let out = rt
226            .run_turn(TurnInput {
227                user_message: "hello".into(),
228                tools_invoked: vec!["noop".into()],
229                trace_event: None,
230                depth: 0,
231                ..Default::default()
232            })
233            .expect("turn");
234        assert!(matches!(out.outcome, TurnOutcome::Success));
235        assert_ne!(out.episode_id, Uuid::nil());
236        assert!(out.extraction_report.is_some());
237    }
238}