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    /// Wall time for `execute_patch` when an adapter ran; zero if no adapter matched or the node was skipped before dispatch.
129    pub dispatch_duration_ms: u64,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum PatchSkipReason {
134    MissingDeclaredRead(String),
135    Retired,
136    ZeroVersion,
137    /// Node was not a procedural patch payload.
138    NotProcedural,
139    /// Failed to persist fitness update.
140    PersistFailed(String),
141}
142
143impl fmt::Display for PatchSkipReason {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self {
146            PatchSkipReason::MissingDeclaredRead(s) => write!(f, "missing_declared_read:{s}"),
147            PatchSkipReason::Retired => write!(f, "retired"),
148            PatchSkipReason::ZeroVersion => write!(f, "zero_version"),
149            PatchSkipReason::NotProcedural => write!(f, "not_procedural"),
150            PatchSkipReason::PersistFailed(s) => write!(f, "persist_failed:{s}"),
151        }
152    }
153}
154
155/// A loaded, validated AINL graph artifact (memory substrate view for one agent).
156#[derive(Debug, Clone)]
157pub struct AinlGraphArtifact {
158    pub agent_id: String,
159    pub snapshot: AgentGraphSnapshot,
160    pub validation: GraphValidationReport,
161}
162
163impl AinlGraphArtifact {
164    /// Load agent graph from store. Fails if validation reports dangling edges.
165    pub fn load(store: &SqliteGraphStore, agent_id: &str) -> Result<Self, String> {
166        let snapshot = store.export_graph(agent_id)?;
167        let validation = store.validate_graph(agent_id)?;
168        if !validation.is_valid {
169            let mut msg = String::from("graph validation failed: dangling edges");
170            for d in &validation.dangling_edge_details {
171                msg.push_str(&format!(
172                    "; {} -> {} [{}]",
173                    d.source_id, d.target_id, d.edge_type
174                ));
175            }
176            return Err(msg);
177        }
178        Ok(Self {
179            agent_id: agent_id.to_string(),
180            snapshot,
181            validation,
182        })
183    }
184
185    /// Wrap a snapshot without re-validating (tests / transfer). Caller must validate separately if needed.
186    pub fn from_snapshot(snapshot: AgentGraphSnapshot) -> Self {
187        let agent_id = snapshot.agent_id.clone();
188        let node_count = snapshot.nodes.len();
189        let edge_count = snapshot.edges.len();
190        let validation = GraphValidationReport {
191            agent_id: agent_id.clone(),
192            node_count,
193            edge_count,
194            dangling_edges: Vec::new(),
195            dangling_edge_details: Vec::new(),
196            cross_agent_boundary_edges: 0,
197            orphan_nodes: Vec::new(),
198            is_valid: true,
199        };
200        Self {
201            agent_id,
202            snapshot,
203            validation,
204        }
205    }
206}
207
208/// Input for a single agent turn (host fills; runtime does not call LLMs).
209#[derive(Debug, Clone, Default, Serialize, Deserialize)]
210#[serde(default)]
211pub struct TurnInput {
212    pub user_message: String,
213    pub tools_invoked: Vec<String>,
214    pub trace_event: Option<serde_json::Value>,
215    /// Caller-supplied depth hint for metadata/logging only — enforcement uses internal [`crate::AinlRuntime`] depth.
216    pub depth: u32,
217    /// Frame variables required by procedural `declared_reads` during patch dispatch.
218    pub frame: HashMap<String, serde_json::Value>,
219    /// After the episode row is written, `EMIT_TO` edges are inserted from `episode_id` to each target
220    /// (additive; default empty). Hosts/tests use this to wire emit routing in the same turn.
221    pub emit_targets: Vec<Uuid>,
222    /// Cognitive vitals from the LLM completion that produced this turn (if available).
223    /// Written onto the [`EpisodicNode`](ainl_memory::EpisodicNode) during episode persistence.
224    /// `None` for providers that do not return logprobs (Anthropic, Ollama, etc.).
225    pub vitals_gate: Option<String>,
226    pub vitals_phase: Option<String>,
227    pub vitals_trust: Option<f32>,
228}
229
230/// Compiled memory context for a turn (prompt-side assembly in the host).
231///
232/// The **`relevant_semantic`** slice is ranked by [`crate::AinlRuntime::compile_memory_context_for`]
233/// from that method’s `user_message` only: **`None` does not inherit the previous episode’s text**
234/// for topic ranking (use `Some(user_message)` when you want topic-aware order, or call
235/// [`crate::AinlRuntime::run_turn`], which always passes the current turn text).
236#[derive(Debug, Clone)]
237pub struct MemoryContext {
238    pub recent_episodes: Vec<AinlMemoryNode>,
239    pub relevant_semantic: Vec<AinlMemoryNode>,
240    pub active_patches: Vec<AinlMemoryNode>,
241    pub persona_snapshot: Option<PersonaSnapshot>,
242    pub compiled_at: DateTime<Utc>,
243}
244
245impl Default for MemoryContext {
246    fn default() -> Self {
247        Self {
248            recent_episodes: Vec::new(),
249            relevant_semantic: Vec::new(),
250            active_patches: Vec::new(),
251            persona_snapshot: None,
252            compiled_at: Utc::now(),
253        }
254    }
255}
256
257/// Non-fatal bookkeeping phase inside [`AinlRuntime::run_turn`] (SQLite / export / persona persistence).
258///
259/// Scheduled graph extraction maps [`ainl_graph_extractor::ExtractionReport`] fields onto
260/// **`ExtractionPass`**, **`PatternPersistence`**, and **`PersonaEvolution`** so hosts can tell
261/// signal-merge failures apart from pattern flush vs persona row writes.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
263pub enum TurnPhase {
264    /// Episode row / `EMIT_TO` / emit routing writes.
265    EpisodeWrite,
266    /// Procedural fitness EMA write-back.
267    FitnessWriteBack,
268    /// Graph / heuristic extraction or persona-row probe (`ExtractionReport::extract_error`).
269    ExtractionPass,
270    /// Semantic recurrence update or episode tag flush (`ExtractionReport::pattern_error`).
271    PatternPersistence,
272    /// Evolution persona snapshot write (`ExtractionReport::persona_error`).
273    PersonaEvolution,
274    /// ArmaraOS graph JSON export refresh (`AINL_GRAPH_MEMORY_ARMARAOS_EXPORT`).
275    ExportRefresh,
276    /// Persisted session counters / persona cache snapshot (SQLite `runtime_state` row).
277    RuntimeStatePersist,
278}
279
280/// One non-fatal failure recorded during a turn (the turn still returns a usable [`TurnResult`]).
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct TurnWarning {
283    pub phase: TurnPhase,
284    pub error: String,
285}
286
287/// Soft outcome for step caps / disabled graph (not store write failures — those become [`TurnWarning`]).
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum TurnStatus {
290    Ok,
291    StepLimitExceeded { steps_executed: u32 },
292    GraphMemoryDisabled,
293}
294
295/// Payload from a finished turn (memory context, episode id, patch dispatch, etc.).
296#[derive(Debug, Clone)]
297pub struct TurnResult {
298    pub episode_id: Uuid,
299    pub persona_prompt_contribution: Option<String>,
300    pub memory_context: MemoryContext,
301    pub extraction_report: Option<ExtractionReport>,
302    pub steps_executed: u32,
303    pub patch_dispatch_results: Vec<PatchDispatchResult>,
304    pub status: TurnStatus,
305    /// Cognitive vitals persisted on the episode node (echoed from `TurnInput`).
306    pub vitals_gate: Option<String>,
307    pub vitals_phase: Option<String>,
308    pub vitals_trust: Option<f32>,
309}
310
311impl Default for TurnResult {
312    fn default() -> Self {
313        Self {
314            episode_id: Uuid::nil(),
315            persona_prompt_contribution: None,
316            memory_context: MemoryContext::default(),
317            extraction_report: None,
318            steps_executed: 0,
319            patch_dispatch_results: Vec::new(),
320            status: TurnStatus::Ok,
321            vitals_gate: None,
322            vitals_phase: None,
323            vitals_trust: None,
324        }
325    }
326}
327
328/// Full success vs partial success after non-fatal write failures.
329#[derive(Debug, Clone)]
330pub enum TurnOutcome {
331    /// All bookkeeping writes succeeded.
332    Complete(TurnResult),
333    /// Turn completed but one or more non-fatal writes failed; [`TurnResult`] is still valid.
334    PartialSuccess {
335        result: TurnResult,
336        warnings: Vec<TurnWarning>,
337    },
338}
339
340impl TurnOutcome {
341    pub fn result(&self) -> &TurnResult {
342        match self {
343            TurnOutcome::Complete(r) | TurnOutcome::PartialSuccess { result: r, .. } => r,
344        }
345    }
346
347    pub fn warnings(&self) -> &[TurnWarning] {
348        match self {
349            TurnOutcome::Complete(_) => &[],
350            TurnOutcome::PartialSuccess { warnings, .. } => warnings.as_slice(),
351        }
352    }
353
354    pub fn into_result(self) -> TurnResult {
355        match self {
356            TurnOutcome::Complete(r) | TurnOutcome::PartialSuccess { result: r, .. } => r,
357        }
358    }
359
360    pub fn is_complete(&self) -> bool {
361        matches!(self, TurnOutcome::Complete(_))
362    }
363
364    pub fn is_partial_success(&self) -> bool {
365        matches!(self, TurnOutcome::PartialSuccess { .. })
366    }
367
368    pub fn turn_status(&self) -> TurnStatus {
369        self.result().status
370    }
371}