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    ApprovalRequest, ConsultError, ConsultRequest, DenyAllTools, HumanChannel, Interlocutor,
85    ScriptedAnswers, ScriptedApprovals, 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 question could not be put to a person, or was not answered.
135    ///
136    /// Not a refusal like [`RunError::ApprovalDenied`]: a gate has a safe answer
137    /// and a question does not, so there is nothing to carry on with.
138    ConsultFailed {
139        node: String,
140        question: String,
141        reason: String,
142    },
143    /// A `verify` ran its check and the property did not hold.
144    ///
145    /// The `verified` event carrying `failed` is emitted before this is
146    /// returned, so the record says what the check found and then says the run
147    /// ended. Artifacts emitted earlier in the flow stay in the record: they
148    /// happened.
149    VerificationFailed { node: String, verifier: String },
150    /// A budget ran out.
151    BudgetExceeded {
152        budget: String,
153        limit: String,
154        node: String,
155    },
156    /// The model provider failed.
157    Provider { node: String, source: ProviderError },
158    /// A tool failed or was unavailable.
159    Tool { node: String, source: ToolError },
160    /// A sub-agent run failed.
161    SubAgent {
162        node: String,
163        agent: String,
164        source: Box<RunError>,
165    },
166    /// A sub-agent was called but its artifact was not supplied.
167    AgentNotAvailable { node: String, agent: String },
168    /// A response type cannot be requested from a model.
169    UnsupportedResponseType {
170        node: String,
171        ty: String,
172        reason: &'static str,
173    },
174    /// Working memory was read before it was written.
175    StateNotSet { node: String, field: String },
176    /// A value did not match its declared type.
177    TypeMismatch {
178        node: String,
179        what: String,
180        reason: String,
181    },
182    /// The flow finished without producing a declared output.
183    OutputNotProduced { name: String },
184    /// A stored persistent value did not match its declared type.
185    InvalidMemory { field: String, reason: String },
186    /// A snapshot could not be used to continue this artifact.
187    Snapshot(snapshot::SnapshotError),
188    /// Inputs were supplied alongside a resumption that already carries them.
189    InputsAfterResume,
190    /// `stop_at` named a checkpoint the run could not stop at.
191    NotResumable {
192        label: String,
193        /// True when the label exists but sits inside a branch arm or a loop.
194        nested: bool,
195        available: Vec<String>,
196    },
197    /// The store carries a field this artifact does not declare.
198    UnknownMemoryField {
199        field: String,
200        expected: Vec<String>,
201    },
202    /// The artifact's IR major version is not implemented.
203    UnsupportedIrVersion { found: String, supported: String },
204    /// The artifact is internally inconsistent.
205    MalformedIr(String),
206}
207
208impl fmt::Display for RunError {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        match self {
211            RunError::MissingInput { name, ty } => {
212                write!(f, "missing input `{name}` (expected `{ty}`)")
213            }
214            RunError::InvalidInput { name, reason } => {
215                write!(f, "input `{name}` is invalid: {reason}")
216            }
217            RunError::UnknownInput { name, expected } => write!(
218                f,
219                "this agent has no input named `{name}`; it declares: {}",
220                if expected.is_empty() { "none".to_string() } else { expected.join(", ") }
221            ),
222            RunError::CapabilityDenied { node, effect, explicit: true } => write!(
223                f,
224                "node `{node}` needs the `{effect}` effect, which the artifact's policy denies"
225            ),
226            RunError::CapabilityDenied { node, effect, explicit: false } => write!(
227                f,
228                "node `{node}` needs the `{effect}` effect, and the artifact's policy grants no \
229                 rule for it (an absent rule is a denial)"
230            ),
231            RunError::ApprovalDenied { node, reason } => {
232                write!(f, "approval was refused at node `{node}`: {reason}")
233            }
234            RunError::ConsultFailed {
235                node,
236                question,
237                reason,
238            } => write!(
239                f,
240                "the question at node `{node}` was not answered: {reason}
241  asked: {question}"
242            ),
243            RunError::VerificationFailed { node, verifier } => write!(
244                f,
245                "the check `{verifier}` did not hold at node `{node}`, so the run stopped there"
246            ),
247            RunError::BudgetExceeded { budget, limit, node } => write!(
248                f,
249                "the `{budget}` budget of {limit} was exhausted at node `{node}`"
250            ),
251            RunError::Provider { node, source } => write!(f, "at node `{node}`: {source}"),
252            RunError::Tool { node, source } => write!(f, "at node `{node}`: {source}"),
253            RunError::SubAgent { node, agent, source } => {
254                write!(f, "at node `{node}`, sub-agent `{agent}` failed: {source}")
255            }
256            RunError::AgentNotAvailable { node, agent } => write!(
257                f,
258                "node `{node}` calls `{agent}`, whose artifact was not supplied to this run"
259            ),
260            RunError::UnsupportedResponseType { node, ty, reason } => {
261                write!(f, "node `{node}` asks for `{ty}`, which cannot be requested: {reason}")
262            }
263            RunError::StateNotSet { node, field } if node.is_empty() => {
264                write!(f, "`state.{field}` was read before it was written")
265            }
266            RunError::StateNotSet { node, field } => {
267                write!(f, "node `{node}` read `state.{field}` before it was written")
268            }
269            RunError::TypeMismatch { node, what, reason } => {
270                write!(f, "at node `{node}`, {what}: {reason}")
271            }
272            RunError::OutputNotProduced { name } => {
273                write!(f, "the run finished without producing the declared output `{name}`")
274            }
275            RunError::InvalidMemory { field, reason } => write!(
276                f,
277                "the stored value for `memory.{field}` does not match its declared type: {reason}"
278            ),
279            RunError::Snapshot(error) => write!(f, "{error}"),
280            RunError::InputsAfterResume => f.write_str(
281                "a resumption already carries the inputs the run started with
282                   supplying different ones would let the two halves of one run disagree                  about what it was given",
283            ),
284            RunError::NotResumable { label, nested, available } => {
285                let why = if *nested {
286                    format!(
287                        "the checkpoint \"{label}\" is inside a branch or a loop, so a run                          cannot stop at it
288  resuming into one would mean serialising a                          continuation, which is not a file anybody could read"
289                    )
290                } else {
291                    format!("no checkpoint is labelled \"{label}\"")
292                };
293                let offered = if available.is_empty() {
294                    "this agent has no resumable checkpoint".to_string()
295                } else {
296                    format!("resumable checkpoints: {}", available.join(", "))
297                };
298                write!(f, "{why}
299  {offered}")
300            }
301            RunError::UnknownMemoryField { field, expected } => write!(
302                f,
303                "the store carries `{field}`, which this agent does not declare
304                   it declares: {}",
305                if expected.is_empty() { "nothing".to_string() } else { expected.join(", ") }
306            ),
307            RunError::UnsupportedIrVersion { found, supported } => write!(
308                f,
309                "this artifact declares IR version `{found}`; this runtime implements `{supported}`. \
310                 Refusing to run it rather than ignoring the parts it does not understand."
311            ),
312            RunError::MalformedIr(message) => write!(f, "the artifact is malformed: {message}"),
313        }
314    }
315}
316
317impl std::error::Error for RunError {}
318
319impl RunError {
320    /// Whether the failure is the operator's to fix (inputs, approvals,
321    /// missing tools) rather than a defect in the artifact.
322    pub fn is_operator_error(&self) -> bool {
323        matches!(
324            self,
325            RunError::MissingInput { .. }
326                | RunError::InvalidInput { .. }
327                | RunError::UnknownInput { .. }
328                | RunError::ApprovalDenied { .. }
329                | RunError::ConsultFailed { .. }
330                | RunError::AgentNotAvailable { .. }
331                | RunError::Tool {
332                    source: ToolError::NotAvailable(_),
333                    ..
334                }
335        )
336    }
337}