Skip to main content

ainl_runtime/
engine.rs

1//! Loaded graph artifacts and per-turn data shapes for [`crate::AinlRuntime`].
2
3use std::collections::HashMap;
4use std::error::Error;
5use std::fmt;
6
7use ainl_graph_extractor::ExtractionReport;
8use ainl_memory::{
9    AgentGraphSnapshot, AinlMemoryNode, AinlNodeType, GraphValidationReport, ProceduralNode,
10    SqliteGraphStore,
11};
12use ainl_persona::PersonaSnapshot;
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17/// Edge label for emit routing (matches `ainl_graph_edges.label`).
18pub const EMIT_TO_EDGE: &str = "EMIT_TO";
19
20/// Hard failure for [`crate::AinlRuntime::run_turn`] (store open, invalid graph, invalid compile input, etc.).
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum AinlRuntimeError {
23    /// Nested [`crate::AinlRuntime::run_turn`] exceeded [`crate::RuntimeConfig::max_delegation_depth`].
24    DelegationDepthExceeded {
25        depth: u32,
26        max: u32,
27    },
28    Message(String),
29    /// `tokio::task::spawn_blocking` failed while running graph I/O off the async executor.
30    AsyncJoinError(String),
31    /// SQLite / graph store error surfaced from a blocking task in the async turn path (the graph
32    /// mutex there is `std::sync::Mutex`, not `tokio::sync::Mutex`; see crate `README.md`).
33    AsyncStoreError(String),
34}
35
36impl fmt::Display for AinlRuntimeError {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            AinlRuntimeError::DelegationDepthExceeded { depth, max } => {
40                write!(f, "delegation depth exceeded (depth={depth}, max={max})")
41            }
42            AinlRuntimeError::Message(s) => f.write_str(s),
43            AinlRuntimeError::AsyncJoinError(s) => write!(f, "async join error: {s}"),
44            AinlRuntimeError::AsyncStoreError(s) => write!(f, "async store error: {s}"),
45        }
46    }
47}
48
49impl Error for AinlRuntimeError {}
50
51impl From<String> for AinlRuntimeError {
52    fn from(s: String) -> Self {
53        Self::Message(s)
54    }
55}
56
57impl AinlRuntimeError {
58    /// Borrow the payload when this is a [`Self::Message`] error (graph validation, missing `agent_id`, etc.).
59    #[must_use]
60    pub fn message_str(&self) -> Option<&str> {
61        match self {
62            Self::Message(s) => Some(s.as_str()),
63            Self::DelegationDepthExceeded { .. } => None,
64            Self::AsyncJoinError(s) => Some(s.as_str()),
65            Self::AsyncStoreError(s) => Some(s.as_str()),
66        }
67    }
68
69    #[must_use]
70    pub fn is_delegation_depth_exceeded(&self) -> bool {
71        matches!(self, Self::DelegationDepthExceeded { .. })
72    }
73
74    #[must_use]
75    pub fn is_async_join_error(&self) -> bool {
76        matches!(self, Self::AsyncJoinError(_))
77    }
78
79    #[must_use]
80    pub fn is_async_store_error(&self) -> bool {
81        matches!(self, Self::AsyncStoreError(_))
82    }
83
84    /// If this is [`Self::DelegationDepthExceeded`], returns `(depth, max)`.
85    #[must_use]
86    pub fn delegation_depth_exceeded(&self) -> Option<(u32, u32)> {
87        match self {
88            Self::DelegationDepthExceeded { depth, max } => Some((*depth, *max)),
89            Self::Message(_) | Self::AsyncJoinError(_) | Self::AsyncStoreError(_) => None,
90        }
91    }
92}
93
94/// Per-patch inputs for [`crate::PatchAdapter::execute_patch`] (procedural patch nodes).
95///
96/// [`AinlRuntime`] resolves a label-keyed adapter first, then falls back to the reference
97/// [`crate::GraphPatchAdapter`] (registered as [`crate::GraphPatchAdapter::NAME`]) when no adapter
98/// matches the procedural patch `label`.
99#[derive(Debug, Clone, Copy)]
100pub struct PatchDispatchContext<'a> {
101    pub patch_label: &'a str,
102    pub node: &'a AinlMemoryNode,
103    pub frame: &'a HashMap<String, serde_json::Value>,
104}
105
106impl<'a> PatchDispatchContext<'a> {
107    pub fn procedural(&self) -> Option<&'a ProceduralNode> {
108        match &self.node.node_type {
109            AinlNodeType::Procedural { procedural } => Some(procedural),
110            _ => None,
111        }
112    }
113}
114
115/// Result of attempting to dispatch one procedural patch node.
116#[derive(Debug, Clone)]
117pub struct PatchDispatchResult {
118    pub label: String,
119    pub patch_version: u32,
120    pub fitness_before: f32,
121    pub fitness_after: f32,
122    pub dispatched: bool,
123    pub skip_reason: Option<PatchSkipReason>,
124    /// Output from a registered [`crate::PatchAdapter`], if any ran successfully.
125    pub adapter_output: Option<serde_json::Value>,
126    /// Name of the adapter that was invoked (including on execution failure).
127    pub adapter_name: Option<String>,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum PatchSkipReason {
132    MissingDeclaredRead(String),
133    Retired,
134    ZeroVersion,
135    /// Node was not a procedural patch payload.
136    NotProcedural,
137    /// Failed to persist fitness update.
138    PersistFailed(String),
139}
140
141impl fmt::Display for PatchSkipReason {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        match self {
144            PatchSkipReason::MissingDeclaredRead(s) => write!(f, "missing_declared_read:{s}"),
145            PatchSkipReason::Retired => write!(f, "retired"),
146            PatchSkipReason::ZeroVersion => write!(f, "zero_version"),
147            PatchSkipReason::NotProcedural => write!(f, "not_procedural"),
148            PatchSkipReason::PersistFailed(s) => write!(f, "persist_failed:{s}"),
149        }
150    }
151}
152
153/// A loaded, validated AINL graph artifact (memory substrate view for one agent).
154#[derive(Debug, Clone)]
155pub struct AinlGraphArtifact {
156    pub agent_id: String,
157    pub snapshot: AgentGraphSnapshot,
158    pub validation: GraphValidationReport,
159}
160
161impl AinlGraphArtifact {
162    /// Load agent graph from store. Fails if validation reports dangling edges.
163    pub fn load(store: &SqliteGraphStore, agent_id: &str) -> Result<Self, String> {
164        let snapshot = store.export_graph(agent_id)?;
165        let validation = store.validate_graph(agent_id)?;
166        if !validation.is_valid {
167            let mut msg = String::from("graph validation failed: dangling edges");
168            for d in &validation.dangling_edge_details {
169                msg.push_str(&format!(
170                    "; {} -> {} [{}]",
171                    d.source_id, d.target_id, d.edge_type
172                ));
173            }
174            return Err(msg);
175        }
176        Ok(Self {
177            agent_id: agent_id.to_string(),
178            snapshot,
179            validation,
180        })
181    }
182
183    /// Wrap a snapshot without re-validating (tests / transfer). Caller must validate separately if needed.
184    pub fn from_snapshot(snapshot: AgentGraphSnapshot) -> Self {
185        let agent_id = snapshot.agent_id.clone();
186        let node_count = snapshot.nodes.len();
187        let edge_count = snapshot.edges.len();
188        let validation = GraphValidationReport {
189            agent_id: agent_id.clone(),
190            node_count,
191            edge_count,
192            dangling_edges: Vec::new(),
193            dangling_edge_details: Vec::new(),
194            cross_agent_boundary_edges: 0,
195            orphan_nodes: Vec::new(),
196            is_valid: true,
197        };
198        Self {
199            agent_id,
200            snapshot,
201            validation,
202        }
203    }
204}
205
206/// Input for a single agent turn (host fills; runtime does not call LLMs).
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208#[serde(default)]
209pub struct TurnInput {
210    pub user_message: String,
211    pub tools_invoked: Vec<String>,
212    pub trace_event: Option<serde_json::Value>,
213    /// Caller-supplied depth hint for metadata/logging only — enforcement uses internal [`crate::AinlRuntime`] depth.
214    pub depth: u32,
215    /// Frame variables required by procedural `declared_reads` during patch dispatch.
216    pub frame: HashMap<String, serde_json::Value>,
217    /// After the episode row is written, `EMIT_TO` edges are inserted from `episode_id` to each target
218    /// (additive; default empty). Hosts/tests use this to wire emit routing in the same turn.
219    pub emit_targets: Vec<Uuid>,
220    /// Cognitive vitals from the LLM completion that produced this turn (if available).
221    /// Written onto the [`EpisodicNode`](ainl_memory::EpisodicNode) during episode persistence.
222    /// `None` for providers that do not return logprobs (Anthropic, Ollama, etc.).
223    pub vitals_gate: Option<String>,
224    pub vitals_phase: Option<String>,
225    pub vitals_trust: Option<f32>,
226}
227
228/// Compiled memory context for a turn (prompt-side assembly in the host).
229///
230/// The **`relevant_semantic`** slice is ranked by [`crate::AinlRuntime::compile_memory_context_for`]
231/// from that method’s `user_message` only: **`None` does not inherit the previous episode’s text**
232/// for topic ranking (use `Some(user_message)` when you want topic-aware order, or call
233/// [`crate::AinlRuntime::run_turn`], which always passes the current turn text).
234#[derive(Debug, Clone)]
235pub struct MemoryContext {
236    pub recent_episodes: Vec<AinlMemoryNode>,
237    pub relevant_semantic: Vec<AinlMemoryNode>,
238    pub active_patches: Vec<AinlMemoryNode>,
239    pub persona_snapshot: Option<PersonaSnapshot>,
240    pub compiled_at: DateTime<Utc>,
241}
242
243impl Default for MemoryContext {
244    fn default() -> Self {
245        Self {
246            recent_episodes: Vec::new(),
247            relevant_semantic: Vec::new(),
248            active_patches: Vec::new(),
249            persona_snapshot: None,
250            compiled_at: Utc::now(),
251        }
252    }
253}
254
255/// Non-fatal bookkeeping phase inside [`AinlRuntime::run_turn`] (SQLite / export / persona persistence).
256///
257/// Scheduled graph extraction maps [`ainl_graph_extractor::ExtractionReport`] fields onto
258/// **`ExtractionPass`**, **`PatternPersistence`**, and **`PersonaEvolution`** so hosts can tell
259/// signal-merge failures apart from pattern flush vs persona row writes.
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
261pub enum TurnPhase {
262    /// Episode row / `EMIT_TO` / emit routing writes.
263    EpisodeWrite,
264    /// Procedural fitness EMA write-back.
265    FitnessWriteBack,
266    /// Graph / heuristic extraction or persona-row probe (`ExtractionReport::extract_error`).
267    ExtractionPass,
268    /// Semantic recurrence update or episode tag flush (`ExtractionReport::pattern_error`).
269    PatternPersistence,
270    /// Evolution persona snapshot write (`ExtractionReport::persona_error`).
271    PersonaEvolution,
272    /// ArmaraOS graph JSON export refresh (`AINL_GRAPH_MEMORY_ARMARAOS_EXPORT`).
273    ExportRefresh,
274    /// Persisted session counters / persona cache snapshot (SQLite `runtime_state` row).
275    RuntimeStatePersist,
276}
277
278/// One non-fatal failure recorded during a turn (the turn still returns a usable [`TurnResult`]).
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct TurnWarning {
281    pub phase: TurnPhase,
282    pub error: String,
283}
284
285/// Soft outcome for step caps / disabled graph (not store write failures — those become [`TurnWarning`]).
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub enum TurnStatus {
288    Ok,
289    StepLimitExceeded { steps_executed: u32 },
290    GraphMemoryDisabled,
291}
292
293/// Payload from a finished turn (memory context, episode id, patch dispatch, etc.).
294#[derive(Debug, Clone)]
295pub struct TurnResult {
296    pub episode_id: Uuid,
297    pub persona_prompt_contribution: Option<String>,
298    pub memory_context: MemoryContext,
299    pub extraction_report: Option<ExtractionReport>,
300    pub steps_executed: u32,
301    pub patch_dispatch_results: Vec<PatchDispatchResult>,
302    pub status: TurnStatus,
303    /// Cognitive vitals persisted on the episode node (echoed from `TurnInput`).
304    pub vitals_gate: Option<String>,
305    pub vitals_phase: Option<String>,
306    pub vitals_trust: Option<f32>,
307}
308
309impl Default for TurnResult {
310    fn default() -> Self {
311        Self {
312            episode_id: Uuid::nil(),
313            persona_prompt_contribution: None,
314            memory_context: MemoryContext::default(),
315            extraction_report: None,
316            steps_executed: 0,
317            patch_dispatch_results: Vec::new(),
318            status: TurnStatus::Ok,
319            vitals_gate: None,
320            vitals_phase: None,
321            vitals_trust: None,
322        }
323    }
324}
325
326/// Full success vs partial success after non-fatal write failures.
327#[derive(Debug, Clone)]
328pub enum TurnOutcome {
329    /// All bookkeeping writes succeeded.
330    Complete(TurnResult),
331    /// Turn completed but one or more non-fatal writes failed; [`TurnResult`] is still valid.
332    PartialSuccess {
333        result: TurnResult,
334        warnings: Vec<TurnWarning>,
335    },
336}
337
338impl TurnOutcome {
339    pub fn result(&self) -> &TurnResult {
340        match self {
341            TurnOutcome::Complete(r) | TurnOutcome::PartialSuccess { result: r, .. } => r,
342        }
343    }
344
345    pub fn warnings(&self) -> &[TurnWarning] {
346        match self {
347            TurnOutcome::Complete(_) => &[],
348            TurnOutcome::PartialSuccess { warnings, .. } => warnings.as_slice(),
349        }
350    }
351
352    pub fn into_result(self) -> TurnResult {
353        match self {
354            TurnOutcome::Complete(r) | TurnOutcome::PartialSuccess { result: r, .. } => r,
355        }
356    }
357
358    pub fn is_complete(&self) -> bool {
359        matches!(self, TurnOutcome::Complete(_))
360    }
361
362    pub fn is_partial_success(&self) -> bool {
363        matches!(self, TurnOutcome::PartialSuccess { .. })
364    }
365
366    pub fn turn_status(&self) -> TurnStatus {
367        self.result().status
368    }
369}