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}
221
222/// Compiled memory context for a turn (prompt-side assembly in the host).
223///
224/// The **`relevant_semantic`** slice is ranked by [`crate::AinlRuntime::compile_memory_context_for`]
225/// from that method’s `user_message` only: **`None` does not inherit the previous episode’s text**
226/// for topic ranking (use `Some(user_message)` when you want topic-aware order, or call
227/// [`crate::AinlRuntime::run_turn`], which always passes the current turn text).
228#[derive(Debug, Clone)]
229pub struct MemoryContext {
230    pub recent_episodes: Vec<AinlMemoryNode>,
231    pub relevant_semantic: Vec<AinlMemoryNode>,
232    pub active_patches: Vec<AinlMemoryNode>,
233    pub persona_snapshot: Option<PersonaSnapshot>,
234    pub compiled_at: DateTime<Utc>,
235}
236
237impl Default for MemoryContext {
238    fn default() -> Self {
239        Self {
240            recent_episodes: Vec::new(),
241            relevant_semantic: Vec::new(),
242            active_patches: Vec::new(),
243            persona_snapshot: None,
244            compiled_at: Utc::now(),
245        }
246    }
247}
248
249/// Non-fatal bookkeeping phase inside [`AinlRuntime::run_turn`] (SQLite / export / persona persistence).
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
251pub enum TurnPhase {
252    EpisodeWrite,
253    FitnessWriteBack,
254    ExtractionPass,
255    PatternPersistence,
256    PersonaEvolution,
257    ExportRefresh,
258    /// Persisted session counters / persona cache snapshot (SQLite).
259    RuntimeStatePersist,
260}
261
262/// One non-fatal failure recorded during a turn (the turn still returns a usable [`TurnResult`]).
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct TurnWarning {
265    pub phase: TurnPhase,
266    pub error: String,
267}
268
269/// Soft outcome for step caps / disabled graph (not store write failures — those become [`TurnWarning`]).
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum TurnStatus {
272    Ok,
273    StepLimitExceeded { steps_executed: u32 },
274    GraphMemoryDisabled,
275}
276
277/// Payload from a finished turn (memory context, episode id, patch dispatch, etc.).
278#[derive(Debug, Clone)]
279pub struct TurnResult {
280    pub episode_id: Uuid,
281    pub persona_prompt_contribution: Option<String>,
282    pub memory_context: MemoryContext,
283    pub extraction_report: Option<ExtractionReport>,
284    pub steps_executed: u32,
285    pub patch_dispatch_results: Vec<PatchDispatchResult>,
286    pub status: TurnStatus,
287}
288
289impl Default for TurnResult {
290    fn default() -> Self {
291        Self {
292            episode_id: Uuid::nil(),
293            persona_prompt_contribution: None,
294            memory_context: MemoryContext::default(),
295            extraction_report: None,
296            steps_executed: 0,
297            patch_dispatch_results: Vec::new(),
298            status: TurnStatus::Ok,
299        }
300    }
301}
302
303/// Full success vs partial success after non-fatal write failures.
304#[derive(Debug, Clone)]
305pub enum TurnOutcome {
306    /// All bookkeeping writes succeeded.
307    Complete(TurnResult),
308    /// Turn completed but one or more non-fatal writes failed; [`TurnResult`] is still valid.
309    PartialSuccess {
310        result: TurnResult,
311        warnings: Vec<TurnWarning>,
312    },
313}
314
315impl TurnOutcome {
316    pub fn result(&self) -> &TurnResult {
317        match self {
318            TurnOutcome::Complete(r) | TurnOutcome::PartialSuccess { result: r, .. } => r,
319        }
320    }
321
322    pub fn warnings(&self) -> &[TurnWarning] {
323        match self {
324            TurnOutcome::Complete(_) => &[],
325            TurnOutcome::PartialSuccess { warnings, .. } => warnings.as_slice(),
326        }
327    }
328
329    pub fn into_result(self) -> TurnResult {
330        match self {
331            TurnOutcome::Complete(r) | TurnOutcome::PartialSuccess { result: r, .. } => r,
332        }
333    }
334
335    pub fn is_complete(&self) -> bool {
336        matches!(self, TurnOutcome::Complete(_))
337    }
338
339    pub fn is_partial_success(&self) -> bool {
340        matches!(self, TurnOutcome::PartialSuccess { .. })
341    }
342
343    pub fn turn_status(&self) -> TurnStatus {
344        self.result().status
345    }
346}