Skip to main content

ingot_runtime/
lib.rs

1//! The reference interpreter for the Ingot Agent IR.
2//!
3//! This crate is the executable definition of what an IR document *means*. It is
4//! deliberately narrow — see [RFC-0002] and [ADR-0002]. It has no context
5//! management, no provider routing, no session state and no orchestration
6//! features, because its job is to make the IR's semantics precise and testable,
7//! not to be a good place to host an agent.
8//!
9//! ```no_run
10//! use ingot_runtime::{run, RunOptions, ScriptedProvider, DenyAllTools, CollectingSink};
11//! use std::collections::BTreeMap;
12//!
13//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
14//! let ir = ingot_ir::AgentIr::from_json(&std::fs::read_to_string("Brief.ir.json")?)?;
15//! let mut provider = ScriptedProvider::new(vec![serde_json::json!("# Brief\n\n...")]);
16//! let mut tools = DenyAllTools;
17//! let mut events = CollectingSink::default();
18//!
19//! let report = run(
20//!     &ir,
21//!     &BTreeMap::new(),
22//!     &mut provider,
23//!     &mut tools,
24//!     &mut events,
25//!     RunOptions {
26//!         inputs: [("topic".to_string(), serde_json::json!("compilers"))].into(),
27//!         ..RunOptions::default()
28//!     },
29//! )?;
30//!
31//! println!("{}", String::from_utf8_lossy(&report.outputs["brief"].to_bytes()));
32//! # Ok(())
33//! # }
34//! ```
35//!
36//! [RFC-0002]: https://github.com/mathissdupont/ingot/blob/main/rfcs/0002-runtime-execution-model.md
37//! [ADR-0002]: https://github.com/mathissdupont/ingot/blob/main/docs/adr/0002-compiler-not-runtime.md
38
39use std::collections::BTreeMap;
40use std::fmt;
41
42pub mod cassette;
43pub mod catalogue;
44pub mod events;
45mod interp;
46pub mod price;
47pub mod provider;
48pub mod router;
49pub mod schema;
50pub mod snapshot;
51pub mod tools;
52
53/// Shared by every network provider, so there is one retry rule rather than one
54/// per vendor.
55#[cfg(feature = "http")]
56pub mod http;
57
58#[cfg(feature = "anthropic")]
59pub mod anthropic;
60
61#[cfg(feature = "openai")]
62pub mod openai;
63
64#[cfg(feature = "google")]
65pub mod google;
66
67#[cfg(test)]
68mod tests;
69
70pub use cassette::{
71    invocation_digest, load_directory, Cassette, Interaction, RecordingProvider, RecordingTools,
72    ReplayProvider, ReplayToolHost, ScriptedProvider, ToolExchange, CASSETTE_VERSION,
73    SUPPORTED_CASSETTE_VERSIONS,
74};
75pub use catalogue::{ModelConfig, ProviderConfig, ProviderKind};
76pub use events::{Artifact, CollectingSink, EventSink, NullSink, RunEvent, TeeSink, VerifyOutcome};
77pub use interp::{run, AgentRegistry, RunOptions};
78pub use provider::{
79    CompletionRequest, CompletionResponse, ModelProvider, ModelSelection, ProviderError, Usage,
80};
81pub use router::RoutingProvider;
82pub use snapshot::{artifact_digest, Resumption, SnapshotError};
83pub use tools::{
84    ApprovalHandler, ApprovalMode, ApprovalRequest, DenyAllTools, ScriptedApprovals,
85    StaticToolHost, ToolError, ToolHost, ToolInvocation,
86};
87
88/// What a completed run produced.
89#[derive(Debug, Clone, PartialEq)]
90pub struct RunReport {
91    pub agent: String,
92    pub outputs: BTreeMap<String, Artifact>,
93    /// Present when the run stopped at a checkpoint instead of finishing.
94    ///
95    /// The caller writes it down; the interpreter does not touch a filesystem.
96    /// A report with this set has not produced the artifact's declared outputs
97    /// and is not expected to have.
98    pub stopped: Option<snapshot::Resumption>,
99    /// Persistent memory as the run left it, for the caller to write back.
100    ///
101    /// The interpreter does not touch the filesystem, so it hands the store's
102    /// new contents up rather than saving them. Empty when the artifact
103    /// declares no `persistent` block.
104    pub memory: BTreeMap<String, serde_json::Value>,
105    pub usage: Usage,
106    pub steps: u32,
107    /// What the run cost, and every model it could not price.
108    ///
109    /// Empty when the artifact states no `cost` budget: pricing a run nobody
110    /// bounded would be arithmetic for its own sake.
111    pub spend: price::Spend,
112}
113
114/// Why a run stopped.
115///
116/// Every variant names the node it happened at, because "the agent failed" is
117/// not actionable and "node n7 needed `network`, which the policy denies" is.
118#[derive(Debug)]
119pub enum RunError {
120    /// A declared input was not supplied.
121    MissingInput { name: String, ty: String },
122    /// An input was supplied with the wrong type.
123    InvalidInput { name: String, reason: String },
124    /// An input was supplied that the agent does not declare.
125    UnknownInput { name: String, expected: Vec<String> },
126    /// A call needed an effect the artifact's policy does not permit.
127    CapabilityDenied {
128        node: String,
129        effect: String,
130        explicit: bool,
131    },
132    /// An approval gate was refused.
133    ApprovalDenied { node: String, reason: String },
134    /// A `verify` ran its check and the property did not hold.
135    ///
136    /// The `verified` event carrying `failed` is emitted before this is
137    /// returned, so the record says what the check found and then says the run
138    /// ended. Artifacts emitted earlier in the flow stay in the record: they
139    /// happened.
140    VerificationFailed { node: String, verifier: String },
141    /// A budget ran out.
142    BudgetExceeded {
143        budget: String,
144        limit: String,
145        node: String,
146    },
147    /// The model provider failed.
148    Provider { node: String, source: ProviderError },
149    /// A tool failed or was unavailable.
150    Tool { node: String, source: ToolError },
151    /// A sub-agent run failed.
152    SubAgent {
153        node: String,
154        agent: String,
155        source: Box<RunError>,
156    },
157    /// A sub-agent was called but its artifact was not supplied.
158    AgentNotAvailable { node: String, agent: String },
159    /// A response type cannot be requested from a model.
160    UnsupportedResponseType {
161        node: String,
162        ty: String,
163        reason: &'static str,
164    },
165    /// Working memory was read before it was written.
166    StateNotSet { node: String, field: String },
167    /// A value did not match its declared type.
168    TypeMismatch {
169        node: String,
170        what: String,
171        reason: String,
172    },
173    /// The flow finished without producing a declared output.
174    OutputNotProduced { name: String },
175    /// A stored persistent value did not match its declared type.
176    InvalidMemory { field: String, reason: String },
177    /// A snapshot could not be used to continue this artifact.
178    Snapshot(snapshot::SnapshotError),
179    /// Inputs were supplied alongside a resumption that already carries them.
180    InputsAfterResume,
181    /// `stop_at` named a checkpoint the run could not stop at.
182    NotResumable {
183        label: String,
184        /// True when the label exists but sits inside a branch arm or a loop.
185        nested: bool,
186        available: Vec<String>,
187    },
188    /// The store carries a field this artifact does not declare.
189    UnknownMemoryField {
190        field: String,
191        expected: Vec<String>,
192    },
193    /// The artifact's IR major version is not implemented.
194    UnsupportedIrVersion { found: String, supported: String },
195    /// The artifact is internally inconsistent.
196    MalformedIr(String),
197}
198
199impl fmt::Display for RunError {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        match self {
202            RunError::MissingInput { name, ty } => {
203                write!(f, "missing input `{name}` (expected `{ty}`)")
204            }
205            RunError::InvalidInput { name, reason } => {
206                write!(f, "input `{name}` is invalid: {reason}")
207            }
208            RunError::UnknownInput { name, expected } => write!(
209                f,
210                "this agent has no input named `{name}`; it declares: {}",
211                if expected.is_empty() { "none".to_string() } else { expected.join(", ") }
212            ),
213            RunError::CapabilityDenied { node, effect, explicit: true } => write!(
214                f,
215                "node `{node}` needs the `{effect}` effect, which the artifact's policy denies"
216            ),
217            RunError::CapabilityDenied { node, effect, explicit: false } => write!(
218                f,
219                "node `{node}` needs the `{effect}` effect, and the artifact's policy grants no \
220                 rule for it (an absent rule is a denial)"
221            ),
222            RunError::ApprovalDenied { node, reason } => {
223                write!(f, "approval was refused at node `{node}`: {reason}")
224            }
225            RunError::VerificationFailed { node, verifier } => write!(
226                f,
227                "the check `{verifier}` did not hold at node `{node}`, so the run stopped there"
228            ),
229            RunError::BudgetExceeded { budget, limit, node } => write!(
230                f,
231                "the `{budget}` budget of {limit} was exhausted at node `{node}`"
232            ),
233            RunError::Provider { node, source } => write!(f, "at node `{node}`: {source}"),
234            RunError::Tool { node, source } => write!(f, "at node `{node}`: {source}"),
235            RunError::SubAgent { node, agent, source } => {
236                write!(f, "at node `{node}`, sub-agent `{agent}` failed: {source}")
237            }
238            RunError::AgentNotAvailable { node, agent } => write!(
239                f,
240                "node `{node}` calls `{agent}`, whose artifact was not supplied to this run"
241            ),
242            RunError::UnsupportedResponseType { node, ty, reason } => {
243                write!(f, "node `{node}` asks for `{ty}`, which cannot be requested: {reason}")
244            }
245            RunError::StateNotSet { node, field } if node.is_empty() => {
246                write!(f, "`state.{field}` was read before it was written")
247            }
248            RunError::StateNotSet { node, field } => {
249                write!(f, "node `{node}` read `state.{field}` before it was written")
250            }
251            RunError::TypeMismatch { node, what, reason } => {
252                write!(f, "at node `{node}`, {what}: {reason}")
253            }
254            RunError::OutputNotProduced { name } => {
255                write!(f, "the run finished without producing the declared output `{name}`")
256            }
257            RunError::InvalidMemory { field, reason } => write!(
258                f,
259                "the stored value for `memory.{field}` does not match its declared type: {reason}"
260            ),
261            RunError::Snapshot(error) => write!(f, "{error}"),
262            RunError::InputsAfterResume => f.write_str(
263                "a resumption already carries the inputs the run started with
264                   supplying different ones would let the two halves of one run disagree                  about what it was given",
265            ),
266            RunError::NotResumable { label, nested, available } => {
267                let why = if *nested {
268                    format!(
269                        "the checkpoint \"{label}\" is inside a branch or a loop, so a run                          cannot stop at it
270  resuming into one would mean serialising a                          continuation, which is not a file anybody could read"
271                    )
272                } else {
273                    format!("no checkpoint is labelled \"{label}\"")
274                };
275                let offered = if available.is_empty() {
276                    "this agent has no resumable checkpoint".to_string()
277                } else {
278                    format!("resumable checkpoints: {}", available.join(", "))
279                };
280                write!(f, "{why}
281  {offered}")
282            }
283            RunError::UnknownMemoryField { field, expected } => write!(
284                f,
285                "the store carries `{field}`, which this agent does not declare
286                   it declares: {}",
287                if expected.is_empty() { "nothing".to_string() } else { expected.join(", ") }
288            ),
289            RunError::UnsupportedIrVersion { found, supported } => write!(
290                f,
291                "this artifact declares IR version `{found}`; this runtime implements `{supported}`. \
292                 Refusing to run it rather than ignoring the parts it does not understand."
293            ),
294            RunError::MalformedIr(message) => write!(f, "the artifact is malformed: {message}"),
295        }
296    }
297}
298
299impl std::error::Error for RunError {}
300
301impl RunError {
302    /// Whether the failure is the operator's to fix (inputs, approvals,
303    /// missing tools) rather than a defect in the artifact.
304    pub fn is_operator_error(&self) -> bool {
305        matches!(
306            self,
307            RunError::MissingInput { .. }
308                | RunError::InvalidInput { .. }
309                | RunError::UnknownInput { .. }
310                | RunError::ApprovalDenied { .. }
311                | RunError::AgentNotAvailable { .. }
312                | RunError::Tool {
313                    source: ToolError::NotAvailable(_),
314                    ..
315                }
316        )
317    }
318}