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::fmt;
5
6use ainl_graph_extractor::ExtractionReport;
7use ainl_memory::{
8    AgentGraphSnapshot, AinlMemoryNode, AinlNodeType, GraphValidationReport, ProceduralNode,
9    SqliteGraphStore,
10};
11use ainl_persona::PersonaSnapshot;
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16/// Edge label for emit routing (matches `ainl_graph_edges.label`).
17pub const EMIT_TO_EDGE: &str = "EMIT_TO";
18
19/// Per-patch inputs for [`crate::PatchAdapter::execute_patch`] (procedural / GraphPatch nodes).
20///
21/// [`AinlRuntime`] resolves a label-keyed adapter first, then falls back to the reference
22/// [`crate::GraphPatchAdapter`] (registered as [`crate::GraphPatchAdapter::NAME`]) when no adapter
23/// matches the patch IR label.
24#[derive(Debug, Clone, Copy)]
25pub struct PatchDispatchContext<'a> {
26    pub patch_label: &'a str,
27    pub node: &'a AinlMemoryNode,
28    pub frame: &'a HashMap<String, serde_json::Value>,
29}
30
31impl<'a> PatchDispatchContext<'a> {
32    pub fn procedural(&self) -> Option<&'a ProceduralNode> {
33        match &self.node.node_type {
34            AinlNodeType::Procedural { procedural } => Some(procedural),
35            _ => None,
36        }
37    }
38}
39
40/// Result of attempting to dispatch one procedural patch node.
41#[derive(Debug, Clone)]
42pub struct PatchDispatchResult {
43    pub label: String,
44    pub patch_version: u32,
45    pub fitness_before: f32,
46    pub fitness_after: f32,
47    pub dispatched: bool,
48    pub skip_reason: Option<PatchSkipReason>,
49    /// Output from a registered [`crate::PatchAdapter`], if any ran successfully.
50    pub adapter_output: Option<serde_json::Value>,
51    /// Name of the adapter that was invoked (including on execution failure).
52    pub adapter_name: Option<String>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum PatchSkipReason {
57    MissingDeclaredRead(String),
58    Retired,
59    ZeroVersion,
60    /// Node was not a procedural patch payload.
61    NotProcedural,
62    /// Failed to persist fitness update.
63    PersistFailed(String),
64}
65
66impl fmt::Display for PatchSkipReason {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            PatchSkipReason::MissingDeclaredRead(s) => write!(f, "missing_declared_read:{s}"),
70            PatchSkipReason::Retired => write!(f, "retired"),
71            PatchSkipReason::ZeroVersion => write!(f, "zero_version"),
72            PatchSkipReason::NotProcedural => write!(f, "not_procedural"),
73            PatchSkipReason::PersistFailed(s) => write!(f, "persist_failed:{s}"),
74        }
75    }
76}
77
78/// A loaded, validated AINL graph artifact (memory substrate view for one agent).
79#[derive(Debug, Clone)]
80pub struct AinlGraphArtifact {
81    pub agent_id: String,
82    pub snapshot: AgentGraphSnapshot,
83    pub validation: GraphValidationReport,
84}
85
86impl AinlGraphArtifact {
87    /// Load agent graph from store. Fails if validation reports dangling edges.
88    pub fn load(store: &SqliteGraphStore, agent_id: &str) -> Result<Self, String> {
89        let snapshot = store.export_graph(agent_id)?;
90        let validation = store.validate_graph(agent_id)?;
91        if !validation.is_valid {
92            let mut msg = String::from("graph validation failed: dangling edges");
93            for d in &validation.dangling_edge_details {
94                msg.push_str(&format!(
95                    "; {} -> {} [{}]",
96                    d.source_id, d.target_id, d.edge_type
97                ));
98            }
99            return Err(msg);
100        }
101        Ok(Self {
102            agent_id: agent_id.to_string(),
103            snapshot,
104            validation,
105        })
106    }
107
108    /// Wrap a snapshot without re-validating (tests / transfer). Caller must validate separately if needed.
109    pub fn from_snapshot(snapshot: AgentGraphSnapshot) -> Self {
110        let agent_id = snapshot.agent_id.clone();
111        let node_count = snapshot.nodes.len();
112        let edge_count = snapshot.edges.len();
113        let validation = GraphValidationReport {
114            agent_id: agent_id.clone(),
115            node_count,
116            edge_count,
117            dangling_edges: Vec::new(),
118            dangling_edge_details: Vec::new(),
119            cross_agent_boundary_edges: 0,
120            orphan_nodes: Vec::new(),
121            is_valid: true,
122        };
123        Self {
124            agent_id,
125            snapshot,
126            validation,
127        }
128    }
129}
130
131/// Input for a single agent turn (host fills; runtime does not call LLMs).
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133#[serde(default)]
134pub struct TurnInput {
135    pub user_message: String,
136    pub tools_invoked: Vec<String>,
137    pub trace_event: Option<serde_json::Value>,
138    /// Caller-supplied depth hint. Not used for enforcement — internal `delegation_depth` is authoritative.
139    pub depth: u32,
140    /// Frame variables required by procedural `declared_reads` during patch dispatch.
141    pub frame: HashMap<String, serde_json::Value>,
142    /// After the episode row is written, `EMIT_TO` edges are inserted from `episode_id` to each target
143    /// (additive; default empty). Hosts/tests use this to wire emit routing in the same turn.
144    pub emit_targets: Vec<Uuid>,
145}
146
147/// Compiled memory context for a turn (prompt-side assembly in the host).
148#[derive(Debug, Clone)]
149pub struct MemoryContext {
150    pub recent_episodes: Vec<AinlMemoryNode>,
151    pub relevant_semantic: Vec<AinlMemoryNode>,
152    pub active_patches: Vec<AinlMemoryNode>,
153    pub persona_snapshot: Option<PersonaSnapshot>,
154    pub compiled_at: DateTime<Utc>,
155}
156
157impl Default for MemoryContext {
158    fn default() -> Self {
159        Self {
160            recent_episodes: Vec::new(),
161            relevant_semantic: Vec::new(),
162            active_patches: Vec::new(),
163            persona_snapshot: None,
164            compiled_at: Utc::now(),
165        }
166    }
167}
168
169/// Output of a single agent turn orchestrated by [`crate::AinlRuntime`].
170#[derive(Debug, Clone)]
171pub struct TurnOutput {
172    pub episode_id: Uuid,
173    pub persona_prompt_contribution: Option<String>,
174    pub memory_context: MemoryContext,
175    pub extraction_report: Option<ExtractionReport>,
176    pub steps_executed: u32,
177    pub outcome: TurnOutcome,
178    pub patch_dispatch_results: Vec<PatchDispatchResult>,
179}
180
181impl Default for TurnOutput {
182    fn default() -> Self {
183        Self {
184            episode_id: Uuid::nil(),
185            persona_prompt_contribution: None,
186            memory_context: MemoryContext::default(),
187            extraction_report: None,
188            steps_executed: 0,
189            outcome: TurnOutcome::Success,
190            patch_dispatch_results: Vec::new(),
191        }
192    }
193}
194
195/// High-level turn result (soft limits use variants instead of `Err` when `Ok` carries diagnostics).
196#[derive(Debug, Clone)]
197pub enum TurnOutcome {
198    Success,
199    DepthLimitExceeded,
200    StepLimitExceeded {
201        steps_executed: u32,
202    },
203    GraphMemoryDisabled,
204    PartialSuccess {
205        episode_recorded: bool,
206        extraction_failed: bool,
207        patches_failed: Vec<String>,
208        warnings: Vec<String>,
209    },
210    Error(String),
211}