Skip to main content

ingot_runtime/
interp.rs

1//! The interpreter.
2//!
3//! Walks an [`AgentIr`] node graph and executes it. Two properties matter more
4//! than speed:
5//!
6//! * **Every guarantee is re-checked.** Capabilities, budgets, loop bounds and
7//!   approvals are enforced here from the artifact's own data, not trusted
8//!   because a compiler once looked at the source. Whoever runs an artifact is
9//!   often not whoever built it.
10//! * **`parallel` runs sequentially.** The compiler guarantees a map body
11//!   contains no state write, no emission and no checkpoint, so iterations
12//!   cannot observe each other and sequential execution yields the same result.
13//!   The IR node names an opportunity for concurrency, not an obligation.
14
15use std::collections::BTreeMap;
16
17use ingot_ir::{AgentIr, Decision, Node, NodeKind, RefScope, TemplatePart, Value as IrValue};
18use serde_json::{json, Value};
19
20use crate::events::{Artifact, EventSink, RunEvent, VerifyOutcome};
21use crate::price::{parse_micros, Pricing, Spend};
22use crate::provider::{CompletionRequest, ModelProvider, ModelSelection, ProviderError, Usage};
23use crate::schema;
24use crate::snapshot::{self, Resumption};
25use crate::tools::{ApprovalMode, ApprovalRequest, ToolError, ToolHost, ToolInvocation};
26use crate::{RunError, RunReport};
27
28/// Hard ceiling on a single call whose answer arrives as one whole body.
29///
30/// Chosen to stay comfortably inside HTTP timeouts: a service that composes the
31/// entire response before sending it holds the connection open for as long as
32/// the answer takes, and several refuse a larger cap outright unless the
33/// request streams.
34const NON_STREAMING_CEILING: u32 = 16_000;
35
36/// Hard ceiling on a single streamed call.
37///
38/// Higher because the objection above does not apply โ€” text arrives as it is
39/// produced, so nothing waits on the whole answer. Still a ceiling rather than
40/// no limit: `max_tokens` above what a model accepts is a rejected request, and
41/// a number the interpreter picked is easier to explain than a provider's 400.
42const STREAMING_CEILING: u32 = 64_000;
43
44pub struct RunOptions {
45    /// Input name to value.
46    pub inputs: BTreeMap<String, Value>,
47    pub approval: ApprovalMode,
48    /// Stop after this many steps even if the artifact allows more. A backstop
49    /// for artifacts with no `steps` budget.
50    pub max_steps: u32,
51    /// Persistent memory this run starts from, usually loaded from the agent's
52    /// store. Fields absent here start from the artifact's declared value, so
53    /// an empty map is a correct first run rather than a missing one.
54    pub memory: BTreeMap<String, Value>,
55    /// Stop at the resumable checkpoint with this label, and report a snapshot.
56    ///
57    /// A label that names no checkpoint, or one that is not resumable, fails
58    /// before the run starts. Running to completion without stopping would
59    /// leave a caller unable to tell that from a run with no checkpoint.
60    pub stop_at: Option<String>,
61    /// Continue an interrupted run instead of starting one.
62    ///
63    /// The snapshot's inputs, bindings, state, outputs and counters replace
64    /// this run's, and execution begins at the node it names.
65    pub resume: Option<Resumption>,
66    /// What each model costs, so `budget.cost` can be charged.
67    ///
68    /// Empty by default. A run with no prices charges nothing and reports every
69    /// model it could not price, because
70    /// [Runtime 0.1 ยง8](../../../specs/runtime/v0.1.md) says a backend that
71    /// cannot price a request must not pretend to.
72    pub pricing: Pricing,
73}
74
75impl Default for RunOptions {
76    fn default() -> Self {
77        RunOptions {
78            inputs: BTreeMap::new(),
79            approval: ApprovalMode::Deny,
80            max_steps: 1_000,
81            memory: BTreeMap::new(),
82            stop_at: None,
83            resume: None,
84            pricing: Pricing::default(),
85        }
86    }
87}
88
89/// Other agents this run may call.
90pub type AgentRegistry = BTreeMap<String, AgentIr>;
91
92struct Interp<'a> {
93    ir: &'a AgentIr,
94    registry: &'a AgentRegistry,
95    provider: &'a mut dyn ModelProvider,
96    tools: &'a mut dyn ToolHost,
97    sink: &'a mut dyn EventSink,
98    approval: &'a mut ApprovalMode,
99
100    bindings: BTreeMap<String, Value>,
101    state: BTreeMap<String, Value>,
102    /// Persistent memory, seeded before the first node and handed back in the
103    /// report so the caller can write it to the agent's store.
104    memory: BTreeMap<String, Value>,
105    outputs: BTreeMap<String, Artifact>,
106
107    steps: u32,
108    max_steps: u32,
109    usage: Usage,
110    pricing: Pricing,
111    spend: Spend,
112
113    /// The checkpoint label this run stops at, if any.
114    stop_at: Option<String>,
115    /// The checkpoint it stopped at, as `(node, label)`.
116    ///
117    /// Once set, every enclosing region unwinds without running another node.
118    /// It is only ever set at the top level -- a nested checkpoint is not
119    /// resumable -- so the unwinding never abandons a partly finished loop.
120    stopped: Option<(String, String)>,
121    /// The inputs this run was given, kept so a snapshot can carry them.
122    run_inputs: BTreeMap<String, Value>,
123    /// Model and tool calls made, so a snapshot can say where a cassette got to.
124    model_calls: u32,
125    tool_calls: u32,
126}
127
128/// Execute an agent.
129pub fn run(
130    ir: &AgentIr,
131    registry: &AgentRegistry,
132    provider: &mut dyn ModelProvider,
133    tools: &mut dyn ToolHost,
134    sink: &mut dyn EventSink,
135    options: RunOptions,
136) -> Result<RunReport, RunError> {
137    let RunOptions {
138        inputs,
139        mut approval,
140        max_steps,
141        memory,
142        stop_at,
143        resume,
144        pricing,
145    } = options;
146    run_nested(
147        ir,
148        registry,
149        provider,
150        tools,
151        sink,
152        inputs,
153        &mut approval,
154        max_steps,
155        memory,
156        Interruption { stop_at, resume },
157        &pricing,
158    )
159}
160
161/// The body of a run, with the approval mode **borrowed** rather than owned.
162///
163/// A sub-agent has to be able to ask the same operator the parent would, and
164/// the parent has to still be able to ask afterwards. Handing the mode down by
165/// value did the first and broke the second: the handler was consumed by the
166/// first sub-agent call and every later gate in the parent was denied without
167/// anyone being asked.
168#[allow(clippy::too_many_arguments)]
169fn run_nested(
170    ir: &AgentIr,
171    registry: &AgentRegistry,
172    provider: &mut dyn ModelProvider,
173    tools: &mut dyn ToolHost,
174    sink: &mut dyn EventSink,
175    inputs: BTreeMap<String, Value>,
176    approval: &mut ApprovalMode,
177    step_ceiling: u32,
178    // Whatever the caller loaded from the agent's store. A sub-agent gets none
179    // of its caller's: persistent memory belongs to an agent, and a sub-agent
180    // is a fresh run against its own artifact.
181    stored: BTreeMap<String, Value>,
182    // Where this run stops, and where it starts. A sub-agent gets neither: a
183    // stop is a property of the run an operator asked for.
184    interruption: Interruption,
185    // Prices are a property of the deployment, not of an agent, so a sub-agent
186    // is charged with the same ones its caller was.
187    pricing: &Pricing,
188) -> Result<RunReport, RunError> {
189    check_ir_version(ir)?;
190
191    // A resumption carries the inputs the first half ran with. Supplying them
192    // again would let the two halves disagree about what the run was given, so
193    // the snapshot's win outright and a caller that passed different ones is
194    // told rather than quietly overridden.
195    let inputs = match &interruption.resume {
196        Some(snapshot) => {
197            if !inputs.is_empty() && inputs != snapshot.inputs {
198                return Err(RunError::InputsAfterResume);
199            }
200            snapshot.inputs.clone()
201        }
202        None => inputs,
203    };
204
205    let mut bindings = BTreeMap::new();
206    for (name, declared_type) in &ir.inputs {
207        let Some(value) = inputs.get(name) else {
208            return Err(RunError::MissingInput {
209                name: name.clone(),
210                ty: declared_type.clone(),
211            });
212        };
213        schema::validate(value, declared_type, &ir.types).map_err(|reason| {
214            RunError::InvalidInput {
215                name: name.clone(),
216                reason,
217            }
218        })?;
219        bindings.insert(name.clone(), value.clone());
220    }
221    for name in inputs.keys() {
222        if !ir.inputs.contains_key(name) {
223            return Err(RunError::UnknownInput {
224                name: name.clone(),
225                expected: ir.inputs.keys().cloned().collect(),
226            });
227        }
228    }
229
230    // Seed persistent memory before anything runs, so every declared field has
231    // a value and a read can never find one missing. A stored value wins over
232    // the declared one; a field the store does not carry starts from the
233    // artifact. Validating here rather than at the first read means a store
234    // holding the wrong shape stops the run before it spends anything.
235    let mut memory = BTreeMap::new();
236    for (name, field) in &ir.persistent {
237        let value = stored
238            .get(name)
239            .cloned()
240            .unwrap_or_else(|| field.initial.clone());
241        schema::validate(&value, &field.ty, &ir.types).map_err(|reason| {
242            RunError::InvalidMemory {
243                field: name.clone(),
244                reason,
245            }
246        })?;
247        memory.insert(name.clone(), value);
248    }
249    for name in stored.keys() {
250        if !ir.persistent.contains_key(name) {
251            return Err(RunError::UnknownMemoryField {
252                field: name.clone(),
253                expected: ir.persistent.keys().cloned().collect(),
254            });
255        }
256    }
257
258    // Both halves of an interruption are settled before the run starts. A
259    // `--stop-at` that silently never fires, or a resumption checked at the
260    // node it lands on, would both spend tokens before saying no.
261    let Interruption { stop_at, resume } = interruption;
262    if let Some(label) = &stop_at {
263        check_stop_label(ir, label)?;
264    }
265    if let Some(snapshot) = &resume {
266        snapshot.check(ir).map_err(RunError::Snapshot)?;
267    }
268
269    // A resumption replaces what the first half established. Its inputs are the
270    // ones that half ran with, so `inputs` above only validated a caller's
271    // duplicate of them.
272    let entry = match &resume {
273        Some(snapshot) => {
274            bindings = snapshot.bindings.clone();
275            Some(snapshot.resume_at.clone())
276        }
277        None => ir.entry.clone(),
278    };
279
280    sink.emit(RunEvent::RunStarted {
281        agent: ir.agent.clone(),
282        provider: provider.name().to_string(),
283    });
284
285    let max_steps = match ir.budget.steps {
286        Some(limit) if limit >= 0 => (limit as u32).min(step_ceiling),
287        _ => step_ceiling,
288    };
289
290    let mut interp = Interp {
291        ir,
292        registry,
293        provider,
294        tools,
295        sink,
296        approval,
297        bindings,
298        // The counters carry across a stop: a budget bounds a run, and a run
299        // that stopped and continued is one run. Resetting them here would make
300        // `--stop-at` a way to spend twice what the artifact permits.
301        state: resume
302            .as_ref()
303            .map(|snapshot| snapshot.state.clone())
304            .unwrap_or_default(),
305        memory,
306        outputs: resume
307            .as_ref()
308            .map(|snapshot| snapshot.outputs.clone())
309            .unwrap_or_default(),
310        steps: resume.as_ref().map(|snapshot| snapshot.steps).unwrap_or(0),
311        max_steps,
312        usage: resume
313            .as_ref()
314            .map(|snapshot| snapshot.usage)
315            .unwrap_or_default(),
316        pricing: pricing.clone(),
317        spend: resume
318            .as_ref()
319            .map(|snapshot| snapshot.spend.clone())
320            .unwrap_or_default(),
321        stop_at,
322        stopped: None,
323        run_inputs: match &resume {
324            Some(snapshot) => snapshot.inputs.clone(),
325            None => inputs,
326        },
327        model_calls: resume
328            .as_ref()
329            .map(|snapshot| snapshot.model_calls)
330            .unwrap_or(0),
331        tool_calls: resume
332            .as_ref()
333            .map(|snapshot| snapshot.tool_calls)
334            .unwrap_or(0),
335    };
336
337    let result = interp.run_region(entry.as_deref());
338
339    match result {
340        Ok(()) => {
341            let stopped = interp.take_snapshot();
342            let report = RunReport {
343                agent: ir.agent.clone(),
344                outputs: interp.outputs,
345                memory: interp.memory,
346                stopped,
347                usage: interp.usage,
348                steps: interp.steps,
349                spend: interp.spend.clone(),
350            };
351            if let Some(snapshot) = &report.stopped {
352                sink.emit(RunEvent::RunStopped {
353                    node: snapshot.stopped_at.clone(),
354                    label: snapshot.label.clone(),
355                });
356                // No output check. A stopped run has not reached the artifact's
357                // outputs and is not expected to have; `runStopped` is in the
358                // record so a reader can see the check was suppressed rather
359                // than having to infer it.
360                return Ok(report);
361            }
362            sink.emit(RunEvent::RunFinished {
363                steps: report.steps,
364                usage: report.usage,
365            });
366            for name in ir.outputs.keys() {
367                if !report.outputs.contains_key(name) {
368                    return Err(RunError::OutputNotProduced { name: name.clone() });
369                }
370            }
371            Ok(report)
372        }
373        Err(error) => {
374            sink.emit(RunEvent::RunFailed {
375                reason: error.to_string(),
376            });
377            Err(error)
378        }
379    }
380}
381
382/// Where a run stops and where it starts, kept together so a sub-agent can be
383/// handed neither in one word.
384#[derive(Default)]
385struct Interruption {
386    stop_at: Option<String>,
387    resume: Option<Resumption>,
388}
389
390/// Refuse a `--stop-at` that would never fire.
391///
392/// Two different mistakes with two different answers: a label nobody wrote, and
393/// a label on a checkpoint inside a branch arm or a loop body.
394fn check_stop_label(ir: &AgentIr, label: &str) -> Result<(), RunError> {
395    if snapshot::resumable_labels(ir).iter().any(|it| it == label) {
396        return Ok(());
397    }
398    let nested = snapshot::all_checkpoint_labels(ir)
399        .iter()
400        .any(|it| it == label);
401    Err(RunError::NotResumable {
402        label: label.to_string(),
403        nested,
404        available: snapshot::resumable_labels(ir),
405    })
406}
407
408fn check_ir_version(ir: &AgentIr) -> Result<(), RunError> {
409    let major = ir.ir_version.split('.').next().unwrap_or_default();
410    if major != "0" {
411        return Err(RunError::UnsupportedIrVersion {
412            found: ir.ir_version.clone(),
413            supported: ingot_ir::IR_VERSION.to_string(),
414        });
415    }
416    Ok(())
417}
418
419impl Interp<'_> {
420    fn node(&self, id: &str) -> Result<&Node, RunError> {
421        self.ir
422            .node(id)
423            .ok_or_else(|| RunError::MalformedIr(format!("node `{id}` does not exist")))
424    }
425
426    /// The snapshot this run stopped at, if it stopped.
427    fn take_snapshot(&self) -> Option<Resumption> {
428        let (node, label) = self.stopped.clone()?;
429        // The node *after* the checkpoint, so a resumed run does not re-emit
430        // the checkpoint's event. A checkpoint with no successor stopped at the
431        // end of the flow, and there is nothing to continue into.
432        let resume_at = self.ir.node(&node).and_then(|node| node.next.clone())?;
433        Some(Resumption {
434            ingot_snapshot: snapshot::SNAPSHOT_VERSION.to_string(),
435            kind: snapshot::KIND.to_string(),
436            agent: self.ir.agent.clone(),
437            artifact: snapshot::artifact_digest(self.ir),
438            label,
439            stopped_at: node,
440            resume_at,
441            inputs: self.run_inputs.clone(),
442            bindings: self.bindings.clone(),
443            state: self.state.clone(),
444            outputs: self.outputs.clone(),
445            steps: self.steps,
446            usage: self.usage,
447            spend: self.spend.clone(),
448            model_calls: self.model_calls,
449            tool_calls: self.tool_calls,
450        })
451    }
452
453    /// Walk a region from `entry` until a node has no successor.
454    fn run_region(&mut self, entry: Option<&str>) -> Result<(), RunError> {
455        let mut current = entry.map(str::to_string);
456        while let Some(id) = current {
457            if self.stopped.is_some() {
458                return Ok(());
459            }
460            let node = self.node(&id)?.clone();
461            self.sink.emit(RunEvent::NodeStarted {
462                node: node.id.clone(),
463                kind: node.kind.as_str().to_string(),
464            });
465            self.run_node(&node)?;
466            current = node.next.clone();
467        }
468        Ok(())
469    }
470
471    fn run_node(&mut self, node: &Node) -> Result<(), RunError> {
472        match node.kind {
473            NodeKind::LlmCall => self.run_llm_call(node),
474            NodeKind::ToolCall => self.run_tool_call(node),
475            NodeKind::AgentCall => self.run_agent_call(node),
476            NodeKind::Branch => self.run_branch(node),
477            NodeKind::Parallel => self.run_parallel(node),
478            NodeKind::Loop => self.run_loop(node),
479            NodeKind::Approval => self.run_approval(node),
480            NodeKind::Verify => self.run_verify(node),
481            NodeKind::StateRead => self.run_state_read(node),
482            NodeKind::StateWrite => self.run_state_write(node),
483            NodeKind::ArtifactEmit => self.run_emit(node),
484            NodeKind::Checkpoint => {
485                let label = node.label.clone().unwrap_or_default();
486                self.sink.emit(RunEvent::Checkpoint {
487                    node: node.id.clone(),
488                    label: label.clone(),
489                });
490                // After the event, so the checkpoint is in the first half's
491                // record exactly where an uninterrupted run puts it. That is
492                // what makes the two halves concatenate.
493                if node.resumable && self.stop_at.as_deref() == Some(label.as_str()) {
494                    self.stopped = Some((node.id.clone(), label));
495                }
496                Ok(())
497            }
498        }
499    }
500
501    // --- budgets ----------------------------------------------------------
502
503    fn charge_step(&mut self, node: &Node) -> Result<(), RunError> {
504        self.steps += 1;
505        if self.steps > self.max_steps {
506            return Err(RunError::BudgetExceeded {
507                budget: "steps".to_string(),
508                limit: self.max_steps.to_string(),
509                node: node.id.clone(),
510            });
511        }
512        Ok(())
513    }
514
515    /// Charge what a call cost, and stop when the artifact's ceiling is passed.
516    ///
517    /// A call that could not be priced is remembered rather than skipped: the
518    /// total is only a total if nothing was missed, and enforcing a budget
519    /// against a partial total would be the pretending
520    /// [Runtime 0.1 ยง8](../../../specs/runtime/v0.1.md) forbids.
521    fn charge_cost(&mut self, node: &Node, model: &str, usage: Usage) -> Result<(), RunError> {
522        let Some(budget) = self.ir.budget.cost.clone() else {
523            // No ceiling stated. Nothing to charge against, and pricing a run
524            // nobody bounded would be arithmetic for its own sake.
525            return Ok(());
526        };
527        self.spend
528            .add(model, self.pricing.charge(model, usage, &budget.currency));
529
530        let Some(limit) = parse_micros(&budget.amount) else {
531            return Ok(());
532        };
533        if self.spend.is_complete() && self.spend.micros() > limit {
534            return Err(RunError::BudgetExceeded {
535                budget: "cost".to_string(),
536                limit: format!("{} {}", budget.amount, budget.currency.to_ascii_uppercase()),
537                node: node.id.clone(),
538            });
539        }
540        Ok(())
541    }
542
543    fn charge_tokens(&mut self, node: &Node, usage: Usage) -> Result<(), RunError> {
544        self.usage.add(usage);
545        if let Some(limit) = self.ir.budget.tokens {
546            if limit >= 0 && self.usage.total() > limit as u64 {
547                return Err(RunError::BudgetExceeded {
548                    budget: "tokens".to_string(),
549                    limit: limit.to_string(),
550                    node: node.id.clone(),
551                });
552            }
553        }
554        Ok(())
555    }
556
557    // --- policy -----------------------------------------------------------
558
559    /// Re-check the artifact's policy before performing an effect.
560    ///
561    /// Duplicates the compile-time check on purpose: this protects whoever runs
562    /// the artifact, who may never have seen its source.
563    fn check_effects(&mut self, node: &Node, effects: &[String]) -> Result<bool, RunError> {
564        let mut needs_approval = Vec::new();
565        for effect in effects {
566            if effect == "model_access" {
567                continue;
568            }
569            let subject = subject_for_effect(effect);
570            match self.ir.policy.get(subject) {
571                Some(rule) => match rule.decision {
572                    Decision::Allow => {}
573                    Decision::RequireApproval => needs_approval.push(effect.clone()),
574                    Decision::Deny => {
575                        return Err(RunError::CapabilityDenied {
576                            node: node.id.clone(),
577                            effect: effect.clone(),
578                            explicit: true,
579                        })
580                    }
581                },
582                None => {
583                    return Err(RunError::CapabilityDenied {
584                        node: node.id.clone(),
585                        effect: effect.clone(),
586                        explicit: false,
587                    })
588                }
589            }
590        }
591        Ok(!needs_approval.is_empty())
592    }
593
594    // --- nodes ------------------------------------------------------------
595
596    fn run_llm_call(&mut self, node: &Node) -> Result<(), RunError> {
597        self.charge_step(node)?;
598        // Counted before the call rather than after it. A cassette advances its
599        // position on the attempt, so a run that stopped after a failed call
600        // still has to resume past that interaction.
601        self.model_calls += 1;
602
603        let response_type = node
604            .response_type
605            .clone()
606            .ok_or_else(|| RunError::MalformedIr(format!("`{}` has no responseType", node.id)))?;
607        let shape = schema::response_shape(&response_type, &self.ir.types).map_err(|error| {
608            RunError::UnsupportedResponseType {
609                node: node.id.clone(),
610                ty: error.ty,
611                reason: error.reason,
612            }
613        })?;
614
615        let prompt_value = node
616            .prompt
617            .as_ref()
618            .ok_or_else(|| RunError::MalformedIr(format!("`{}` has no prompt", node.id)))?;
619        let prompt = self.render_prompt(prompt_value)?;
620
621        let mut system = None;
622        let mut context = Vec::new();
623        for argument in &node.args {
624            let value = self.eval(&argument.value)?;
625            match argument.name.as_str() {
626                "system" => system = value.as_str().map(str::to_string),
627                // `temperature` and `max_tokens` are model-tuning hints. They
628                // are deliberately not forwarded: provider support varies, and
629                // an artifact that silently behaves differently per provider is
630                // exactly what portability is supposed to prevent.
631                "temperature" | "max_tokens" => {}
632                name => context.push((name.to_string(), value)),
633            }
634        }
635
636        let request = CompletionRequest {
637            node: node.id.clone(),
638            model: self.model_selection(),
639            system,
640            prompt,
641            context,
642            response_type: response_type.clone(),
643            shape,
644            max_tokens: self.max_output_tokens(),
645        };
646
647        // The two channels are borrowed side by side here, and that is the
648        // whole arrangement: text goes out live as it arrives, while the
649        // decision about what the run does with it waits for the finished
650        // response below.
651        let node_id = node.id.clone();
652        let mut shown = false;
653        let attempt = {
654            let sink = &mut *self.sink;
655            self.provider.complete_streaming(&request, &mut |text| {
656                shown = true;
657                sink.delta(&node_id, text);
658            })
659        };
660
661        // A partial answer is not an answer. Whatever a watcher saw is struck
662        // rather than parsed, repaired or bound โ€” including on a truncation,
663        // where the text on screen is the beginning of a real answer and
664        // therefore the most tempting thing in the system to keep.
665        let response = match attempt {
666            Ok(response) => response,
667            Err(error) => {
668                if shown {
669                    self.sink.settled(&node_id, false);
670                }
671                return Err(RunError::Provider {
672                    node: node.id.clone(),
673                    source: error,
674                });
675            }
676        };
677
678        if let Err(reason) = schema::validate(&response.value, &response_type, &self.ir.types) {
679            if shown {
680                self.sink.settled(&node_id, false);
681            }
682            return Err(RunError::Provider {
683                node: node.id.clone(),
684                source: ProviderError::InvalidResponse(reason),
685            });
686        }
687        if shown {
688            self.sink.settled(&node_id, true);
689        }
690
691        self.sink.emit(RunEvent::ModelCall {
692            node: node.id.clone(),
693            model: response.model.clone(),
694            response_type,
695            usage: response.usage,
696        });
697        self.charge_tokens(node, response.usage)?;
698        self.charge_cost(node, &response.model, response.usage)?;
699        self.bind(node, response.value);
700        Ok(())
701    }
702
703    fn run_tool_call(&mut self, node: &Node) -> Result<(), RunError> {
704        self.tool_calls += 1;
705        let reference = node
706            .tool
707            .clone()
708            .ok_or_else(|| RunError::MalformedIr(format!("`{}` names no tool", node.id)))?;
709        let binding = self
710            .ir
711            .tools
712            .iter()
713            .find(|tool| tool.reference == reference)
714            .ok_or_else(|| {
715                RunError::MalformedIr(format!(
716                    "`{}` calls `{reference}`, which the artifact does not grant",
717                    node.id
718                ))
719            })?
720            .clone();
721
722        self.check_effects(node, &node.effects)?;
723        self.charge_step(node)?;
724
725        if !self.tools.provides(&binding.name) {
726            return Err(RunError::Tool {
727                node: node.id.clone(),
728                source: ToolError::NotAvailable(binding.name.clone()),
729            });
730        }
731
732        let mut arguments = BTreeMap::new();
733        for argument in &node.args {
734            arguments.insert(argument.name.clone(), self.eval(&argument.value)?);
735        }
736
737        let invocation = ToolInvocation {
738            node: node.id.clone(),
739            agent: self.ir.agent.clone(),
740            reference: binding.reference.clone(),
741            name: binding.name.clone(),
742            transport: binding.transport.clone(),
743            arguments,
744            effects: node.effects.clone(),
745            result_type: binding.signature.result.clone(),
746        };
747
748        let result = self
749            .tools
750            .call(&invocation)
751            .map_err(|source| RunError::Tool {
752                node: node.id.clone(),
753                source,
754            })?;
755
756        schema::validate(&result, &binding.signature.result, &self.ir.types).map_err(|reason| {
757            RunError::Tool {
758                node: node.id.clone(),
759                source: ToolError::InvalidResult(reason),
760            }
761        })?;
762
763        self.sink.emit(RunEvent::ToolCall {
764            node: node.id.clone(),
765            tool: binding.reference,
766            effects: node.effects.clone(),
767        });
768        self.bind(node, result);
769        Ok(())
770    }
771
772    fn run_agent_call(&mut self, node: &Node) -> Result<(), RunError> {
773        let name = node
774            .agent
775            .clone()
776            .ok_or_else(|| RunError::MalformedIr(format!("`{}` names no agent", node.id)))?;
777        let sub = self
778            .registry
779            .get(&name)
780            .ok_or_else(|| RunError::AgentNotAvailable {
781                node: node.id.clone(),
782                agent: name.clone(),
783            })?;
784
785        self.check_effects(node, &node.effects)?;
786        self.charge_step(node)?;
787        self.sink.emit(RunEvent::AgentCall {
788            node: node.id.clone(),
789            agent: name.clone(),
790        });
791
792        let mut inputs = BTreeMap::new();
793        for argument in &node.args {
794            inputs.insert(argument.name.clone(), self.eval(&argument.value)?);
795        }
796
797        // The sub-agent gets its own budget and its own policy. The parent
798        // cannot widen either: this is a fresh run against the callee's own
799        // artifact, not an inlined continuation of the caller's.
800        //
801        // The approval mode is the one thing that is shared rather than
802        // duplicated. It is the operator, not the artifact, and there is only
803        // one of them.
804        let sub = sub.clone();
805        let report = run_nested(
806            &sub,
807            self.registry,
808            self.provider,
809            self.tools,
810            self.sink,
811            inputs,
812            &mut *self.approval,
813            self.max_steps.saturating_sub(self.steps).max(1),
814            // No store. A sub-agent's persistent memory is its own, and the
815            // interpreter cannot open one anyway โ€” a caller that wants a
816            // sub-agent to keep memory runs it as an agent.
817            BTreeMap::new(),
818            // A sub-agent is never stopped at. A stop is a property of the run
819            // an operator asked for, and half a sub-agent is not something the
820            // caller could hold or continue.
821            Interruption::default(),
822            &self.pricing,
823        )
824        .map_err(|error| RunError::SubAgent {
825            node: node.id.clone(),
826            agent: name.clone(),
827            source: Box::new(error),
828        })?;
829
830        self.steps += report.steps;
831        self.charge_tokens(node, report.usage)?;
832
833        let value = report
834            .outputs
835            .values()
836            .next()
837            .map(|artifact| artifact.value.clone())
838            .unwrap_or(Value::Null);
839        self.bind(node, value);
840        Ok(())
841    }
842
843    fn run_branch(&mut self, node: &Node) -> Result<(), RunError> {
844        let condition = node
845            .condition
846            .as_ref()
847            .ok_or_else(|| RunError::MalformedIr(format!("`{}` has no condition", node.id)))?;
848        let taken = self.eval(condition)?.as_bool().ok_or_else(|| {
849            RunError::MalformedIr(format!(
850                "`{}` condition did not evaluate to a boolean",
851                node.id
852            ))
853        })?;
854
855        self.sink.emit(RunEvent::BranchTaken {
856            node: node.id.clone(),
857            arm: if taken { "then".into() } else { "else".into() },
858        });
859        let arm = if taken {
860            node.then.as_deref()
861        } else {
862            node.otherwise.as_deref()
863        };
864        self.run_region(arm)
865    }
866
867    /// Run a `parallel` body once per element, sequentially.
868    ///
869    /// See the module documentation: the compiler guarantees iterations are
870    /// independent, so this produces the same result as concurrent execution.
871    fn run_parallel(&mut self, node: &Node) -> Result<(), RunError> {
872        let source = node
873            .source
874            .as_ref()
875            .ok_or_else(|| RunError::MalformedIr(format!("`{}` has no source", node.id)))?;
876        let binder = node
877            .binder
878            .clone()
879            .ok_or_else(|| RunError::MalformedIr(format!("`{}` has no binder", node.id)))?;
880        let items = self.eval(source)?;
881        let items = items.as_array().cloned().ok_or_else(|| {
882            RunError::MalformedIr(format!("`{}` source did not evaluate to a list", node.id))
883        })?;
884
885        let last_body_node = node
886            .body
887            .as_deref()
888            .map(|entry| self.last_of_region(entry))
889            .transpose()?
890            .flatten();
891
892        let total = items.len();
893        let mut collected = Vec::with_capacity(total);
894        let shadowed = self.bindings.remove(&binder);
895
896        for (index, item) in items.into_iter().enumerate() {
897            self.sink.emit(RunEvent::MapIteration {
898                node: node.id.clone(),
899                index,
900                total,
901            });
902            self.bindings.insert(binder.clone(), item);
903            self.run_region(node.body.as_deref())?;
904            if self.stopped.is_some() {
905                break;
906            }
907
908            // The value of an iteration is the result of the last node in the
909            // body โ€” the rule the IR specification states.
910            //
911            // A last node with no binding has no result, so an artifact that
912            // ends a map body with one is malformed. It used to collect `null`
913            // per element instead, which is a list of the right length and the
914            // wrong contents: the failure surfaced wherever the list was
915            // eventually used, a long way from the node that caused it.
916            let value = match &last_body_node {
917                Some(id) => {
918                    let last = self.node(id)?.clone();
919                    match &last.binding {
920                        Some(name) => self.bindings.get(name).cloned().unwrap_or(Value::Null),
921                        None => {
922                            return Err(RunError::MalformedIr(format!(
923                                "`{}` ends its body at `{id}`, which binds nothing, so an                                  iteration has no value to collect",
924                                node.id
925                            )))
926                        }
927                    }
928                }
929                None => Value::Null,
930            };
931            collected.push(value);
932        }
933
934        self.bindings.remove(&binder);
935        if let Some(shadowed) = shadowed {
936            self.bindings.insert(binder, shadowed);
937        }
938        self.bind(node, Value::Array(collected));
939        Ok(())
940    }
941
942    fn run_loop(&mut self, node: &Node) -> Result<(), RunError> {
943        // The static bound is enforced here too, so a guard that never
944        // falsifies cannot produce an unbounded run.
945        let max = node.max_iterations.unwrap_or(0).max(0) as u32;
946        for iteration in 0..max {
947            if let Some(guard) = &node.guard {
948                let keep_going = self.eval(guard)?.as_bool().ok_or_else(|| {
949                    RunError::MalformedIr(format!(
950                        "`{}` guard did not evaluate to a boolean",
951                        node.id
952                    ))
953                })?;
954                if !keep_going {
955                    break;
956                }
957            }
958            self.sink.emit(RunEvent::LoopIteration {
959                node: node.id.clone(),
960                iteration: iteration + 1,
961            });
962            self.run_region(node.body.as_deref())?;
963            // Unreachable today -- a checkpoint inside a loop is not resumable,
964            // so nothing in a body can set this -- and here so that the day one
965            // can, the loop does not run its remaining iterations first.
966            if self.stopped.is_some() {
967                break;
968            }
969        }
970        Ok(())
971    }
972
973    fn run_approval(&mut self, node: &Node) -> Result<(), RunError> {
974        let reason = node
975            .label
976            .clone()
977            .unwrap_or_else(|| "approval required".to_string());
978        self.sink.emit(RunEvent::ApprovalRequested {
979            node: node.id.clone(),
980            effects: node.effects.clone(),
981            reason: reason.clone(),
982        });
983
984        let allowed = match self.approval {
985            ApprovalMode::AssumeYes => true,
986            ApprovalMode::Deny => false,
987            ApprovalMode::Ask(handler) => handler.approve(&ApprovalRequest {
988                node: node.id.clone(),
989                effects: node.effects.clone(),
990                reason: reason.clone(),
991            }),
992        };
993
994        self.sink.emit(RunEvent::ApprovalDecided {
995            node: node.id.clone(),
996            allowed,
997        });
998        if allowed {
999            Ok(())
1000        } else {
1001            Err(RunError::ApprovalDenied {
1002                node: node.id.clone(),
1003                reason,
1004            })
1005        }
1006    }
1007
1008    fn run_verify(&mut self, node: &Node) -> Result<(), RunError> {
1009        let verifier = node
1010            .verifier
1011            .clone()
1012            .ok_or_else(|| RunError::MalformedIr(format!("`{}` names no verifier", node.id)))?;
1013        for argument in &node.args {
1014            self.eval(&argument.value)?;
1015        }
1016
1017        // No `condition` means the source declared a verifier without a body:
1018        // the artifact names a check and carries no way to perform it. Saying
1019        // so is the whole point โ€” `passed: true` here would be a pass nothing
1020        // earned.
1021        let Some(condition) = &node.condition else {
1022            self.sink.emit(RunEvent::Verified {
1023                node: node.id.clone(),
1024                verifier: verifier.clone(),
1025                outcome: VerifyOutcome::NotPerformed,
1026            });
1027            return Ok(());
1028        };
1029
1030        let held = self.eval(condition)?.as_bool().ok_or_else(|| {
1031            RunError::MalformedIr(format!(
1032                "`{}` condition did not evaluate to a boolean",
1033                node.id
1034            ))
1035        })?;
1036
1037        self.sink.emit(RunEvent::Verified {
1038            node: node.id.clone(),
1039            verifier: verifier.clone(),
1040            outcome: if held {
1041                VerifyOutcome::Passed
1042            } else {
1043                VerifyOutcome::Failed
1044            },
1045        });
1046
1047        // The event is emitted first, so the record says what the check found
1048        // before it says the run ended. A failure ends the run rather than
1049        // letting it finish under a property that does not hold.
1050        if held {
1051            Ok(())
1052        } else {
1053            Err(RunError::VerificationFailed {
1054                node: node.id.clone(),
1055                verifier: verifier.clone(),
1056            })
1057        }
1058    }
1059
1060    fn run_state_read(&mut self, node: &Node) -> Result<(), RunError> {
1061        let field = node
1062            .field
1063            .clone()
1064            .ok_or_else(|| RunError::MalformedIr(format!("`{}` names no state field", node.id)))?;
1065        let value = match node.scope {
1066            Some(RefScope::Memory) => self.memory.get(&field).cloned().ok_or_else(|| {
1067                RunError::MalformedIr(format!(
1068                    "`{}` reads `memory.{field}`, which is not declared",
1069                    node.id
1070                ))
1071            })?,
1072            _ => self
1073                .state
1074                .get(&field)
1075                .cloned()
1076                .ok_or_else(|| RunError::StateNotSet {
1077                    node: node.id.clone(),
1078                    field: field.clone(),
1079                })?,
1080        };
1081        self.bind(node, value);
1082        Ok(())
1083    }
1084
1085    fn run_state_write(&mut self, node: &Node) -> Result<(), RunError> {
1086        let field = node
1087            .field
1088            .clone()
1089            .ok_or_else(|| RunError::MalformedIr(format!("`{}` names no state field", node.id)))?;
1090        let value = node
1091            .value
1092            .as_ref()
1093            .ok_or_else(|| RunError::MalformedIr(format!("`{}` has no value", node.id)))?;
1094        let value = self.eval(value)?;
1095        let persistent = matches!(node.scope, Some(RefScope::Memory));
1096        let declared = if persistent {
1097            self.ir.persistent.get(&field).map(|field| field.ty.clone())
1098        } else {
1099            self.ir.state.get(&field).cloned()
1100        };
1101        if let Some(declared) = declared {
1102            let root = if persistent { "memory" } else { "state" };
1103            schema::validate(&value, &declared, &self.ir.types).map_err(|reason| {
1104                RunError::TypeMismatch {
1105                    node: node.id.clone(),
1106                    what: format!("{root}.{field}"),
1107                    reason,
1108                }
1109            })?;
1110        }
1111        if persistent {
1112            self.memory.insert(field.clone(), value);
1113        } else {
1114            self.state.insert(field.clone(), value);
1115        }
1116        self.sink.emit(RunEvent::StateWritten {
1117            node: node.id.clone(),
1118            field,
1119        });
1120        Ok(())
1121    }
1122
1123    fn run_emit(&mut self, node: &Node) -> Result<(), RunError> {
1124        let output = node
1125            .output
1126            .clone()
1127            .ok_or_else(|| RunError::MalformedIr(format!("`{}` names no output", node.id)))?;
1128        let value = node
1129            .value
1130            .as_ref()
1131            .ok_or_else(|| RunError::MalformedIr(format!("`{}` has no value", node.id)))?;
1132        let value = self.eval(value)?;
1133
1134        let declared = self.ir.outputs.get(&output).cloned().ok_or_else(|| {
1135            RunError::MalformedIr(format!(
1136                "`{}` emits `{output}`, which is not declared",
1137                node.id
1138            ))
1139        })?;
1140        let content_type = declared
1141            .strip_prefix("artifact<")
1142            .and_then(|rest| rest.strip_suffix('>'))
1143            .unwrap_or(&declared)
1144            .to_string();
1145        schema::validate(&value, &content_type, &self.ir.types).map_err(|reason| {
1146            RunError::TypeMismatch {
1147                node: node.id.clone(),
1148                what: format!("output `{output}`"),
1149                reason,
1150            }
1151        })?;
1152
1153        self.outputs.insert(
1154            output.clone(),
1155            Artifact {
1156                name: output.clone(),
1157                content_type,
1158                value,
1159            },
1160        );
1161        self.sink.emit(RunEvent::Emitted {
1162            node: node.id.clone(),
1163            output,
1164        });
1165        Ok(())
1166    }
1167
1168    // --- helpers ----------------------------------------------------------
1169
1170    fn bind(&mut self, node: &Node, value: Value) {
1171        if let Some(name) = &node.binding {
1172            self.bindings.insert(name.clone(), value);
1173        }
1174    }
1175
1176    /// Id of the last node in a region, following `next` to the end.
1177    fn last_of_region(&self, entry: &str) -> Result<Option<String>, RunError> {
1178        let mut current = Some(entry.to_string());
1179        let mut last = None;
1180        let mut visited = 0usize;
1181        while let Some(id) = current {
1182            let node = self.node(&id)?;
1183            last = Some(node.id.clone());
1184            current = node.next.clone();
1185            visited += 1;
1186            if visited > self.ir.nodes.len() {
1187                return Err(RunError::MalformedIr(
1188                    "the node graph contains a cycle".to_string(),
1189                ));
1190            }
1191        }
1192        Ok(last)
1193    }
1194
1195    fn model_selection(&self) -> ModelSelection {
1196        match &self.ir.requirements.model {
1197            ingot_ir::ModelRequirement::Exact { reference } => {
1198                ModelSelection::Exact(reference.clone())
1199            }
1200            ingot_ir::ModelRequirement::Capabilities {
1201                capabilities,
1202                context_tokens,
1203            } => ModelSelection::Capabilities {
1204                capabilities: capabilities.clone(),
1205                min_context_tokens: context_tokens.as_ref().map(|tokens| tokens.min),
1206            },
1207            ingot_ir::ModelRequirement::Unspecified => ModelSelection::Default,
1208        }
1209    }
1210
1211    /// The cap on one call: what is left of the token budget, or the ceiling.
1212    ///
1213    /// Bounded from below by 1, because asking a provider for zero tokens is a
1214    /// request that cannot succeed, and from above by whichever ceiling the
1215    /// transport earns. An artifact does not choose this and cannot: the same
1216    /// artifact run against a streaming provider and a non-streaming one is the
1217    /// same program, and only the second has to keep the smaller number.
1218    fn max_output_tokens(&self) -> u32 {
1219        let ceiling = if self.provider.streams() {
1220            STREAMING_CEILING
1221        } else {
1222            NON_STREAMING_CEILING
1223        };
1224        let remaining = match self.ir.budget.tokens {
1225            Some(limit) if limit >= 0 => (limit as u64)
1226                .saturating_sub(self.usage.total())
1227                .min(u32::MAX as u64) as u32,
1228            _ => ceiling,
1229        };
1230        remaining.clamp(1, ceiling)
1231    }
1232
1233    fn render_prompt(&mut self, value: &IrValue) -> Result<String, RunError> {
1234        match value {
1235            IrValue::Literal {
1236                value: Value::String(text),
1237                ..
1238            } => Ok(text.clone()),
1239            IrValue::Template { parts } => {
1240                let mut out = String::new();
1241                for part in parts {
1242                    match part {
1243                        TemplatePart::Text { value } => out.push_str(value),
1244                        TemplatePart::Value { value, ty } => {
1245                            let resolved = self.eval(value)?;
1246                            out.push_str(&render_value(&resolved, ty));
1247                        }
1248                    }
1249                }
1250                Ok(out)
1251            }
1252            other => {
1253                let resolved = self.eval(other)?;
1254                Ok(render_value(&resolved, "string"))
1255            }
1256        }
1257    }
1258
1259    /// Evaluate a pure IR value.
1260    fn eval(&mut self, value: &IrValue) -> Result<Value, RunError> {
1261        match value {
1262            IrValue::Literal { value, .. } => Ok(value.clone()),
1263            IrValue::Ref { scope, path } => self.eval_ref(*scope, path),
1264            IrValue::List { items } => {
1265                let mut out = Vec::with_capacity(items.len());
1266                for item in items {
1267                    out.push(self.eval(item)?);
1268                }
1269                Ok(Value::Array(out))
1270            }
1271            IrValue::Template { .. } => Ok(Value::String(self.render_prompt(value)?)),
1272            IrValue::Unary { op, operand } => {
1273                let operand = self.eval(operand)?;
1274                match op.as_str() {
1275                    "!" => Ok(json!(!truthy(&operand))),
1276                    "-" => match operand.as_i64() {
1277                        Some(int) => Ok(json!(-int)),
1278                        None => Ok(json!(-operand.as_f64().unwrap_or_default())),
1279                    },
1280                    other => Err(RunError::MalformedIr(format!(
1281                        "unknown unary operator `{other}`"
1282                    ))),
1283                }
1284            }
1285            IrValue::Binary { op, lhs, rhs } => {
1286                let lhs = self.eval(lhs)?;
1287                let rhs = self.eval(rhs)?;
1288                eval_binary(op, &lhs, &rhs)
1289            }
1290            IrValue::Builtin { name, args } => {
1291                let mut evaluated = Vec::with_capacity(args.len());
1292                for arg in args {
1293                    evaluated.push(self.eval(arg)?);
1294                }
1295                eval_builtin(name, &evaluated)
1296            }
1297            IrValue::Unknown => Err(RunError::MalformedIr(
1298                "the artifact contains an unresolved value; it was not built from a clean compile"
1299                    .to_string(),
1300            )),
1301        }
1302    }
1303
1304    fn eval_ref(&mut self, scope: RefScope, path: &[String]) -> Result<Value, RunError> {
1305        let Some((root, fields)) = path.split_first() else {
1306            return Err(RunError::MalformedIr(
1307                "a reference has an empty path".to_string(),
1308            ));
1309        };
1310        let mut current = match scope {
1311            RefScope::Input | RefScope::Binding => {
1312                self.bindings.get(root).cloned().ok_or_else(|| {
1313                    RunError::MalformedIr(format!("`{root}` is not bound at this point"))
1314                })?
1315            }
1316            RefScope::State => {
1317                self.state
1318                    .get(root)
1319                    .cloned()
1320                    .ok_or_else(|| RunError::StateNotSet {
1321                        node: String::new(),
1322                        field: root.clone(),
1323                    })?
1324            }
1325            // Never absent: every persistent field is seeded from its declared
1326            // initial value before the first node runs, which is the whole
1327            // reason that value is required.
1328            RefScope::Memory => self.memory.get(root).cloned().ok_or_else(|| {
1329                RunError::MalformedIr(format!("`memory.{root}` is not a declared field"))
1330            })?,
1331        };
1332        for field in fields {
1333            current = current
1334                .get(field)
1335                .cloned()
1336                .ok_or_else(|| RunError::MalformedIr(format!("no field `{field}` on the value")))?;
1337        }
1338        Ok(current)
1339    }
1340}
1341
1342fn render_value(value: &Value, ty: &str) -> String {
1343    match value {
1344        // Substituting a string into a prompt should insert the text, not a
1345        // JSON-quoted copy of it.
1346        Value::String(text) => text.clone(),
1347        Value::Null => String::new(),
1348        other => match ty {
1349            "json" => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()),
1350            _ => other.to_string(),
1351        },
1352    }
1353}
1354
1355fn truthy(value: &Value) -> bool {
1356    value.as_bool().unwrap_or(false)
1357}
1358
1359fn eval_binary(op: &str, lhs: &Value, rhs: &Value) -> Result<Value, RunError> {
1360    let result = match op {
1361        "==" => json!(lhs == rhs),
1362        "!=" => json!(lhs != rhs),
1363        "&&" => json!(truthy(lhs) && truthy(rhs)),
1364        "||" => json!(truthy(lhs) || truthy(rhs)),
1365        "<" | "<=" | ">" | ">=" => {
1366            let (a, b) = numeric_pair(lhs, rhs, op)?;
1367            json!(match op {
1368                "<" => a < b,
1369                "<=" => a <= b,
1370                ">" => a > b,
1371                _ => a >= b,
1372            })
1373        }
1374        "+" | "-" => {
1375            if let (Some(a), Some(b)) = (lhs.as_i64(), rhs.as_i64()) {
1376                json!(if op == "+" { a + b } else { a - b })
1377            } else {
1378                let (a, b) = numeric_pair(lhs, rhs, op)?;
1379                json!(if op == "+" { a + b } else { a - b })
1380            }
1381        }
1382        other => {
1383            return Err(RunError::MalformedIr(format!(
1384                "unknown binary operator `{other}`"
1385            )));
1386        }
1387    };
1388    Ok(result)
1389}
1390
1391fn numeric_pair(lhs: &Value, rhs: &Value, op: &str) -> Result<(f64, f64), RunError> {
1392    match (lhs.as_f64(), rhs.as_f64()) {
1393        (Some(a), Some(b)) => Ok((a, b)),
1394        _ => Err(RunError::MalformedIr(format!(
1395            "`{op}` needs two numbers but was given {lhs} and {rhs}"
1396        ))),
1397    }
1398}
1399
1400fn eval_builtin(name: &str, args: &[Value]) -> Result<Value, RunError> {
1401    match name {
1402        "len" => {
1403            let Some(value) = args.first() else {
1404                return Err(RunError::MalformedIr(
1405                    "`len` takes one argument".to_string(),
1406                ));
1407            };
1408            let length = match value {
1409                Value::Array(items) => items.len(),
1410                Value::String(text) => text.chars().count(),
1411                Value::Object(map) => map.len(),
1412                other => {
1413                    return Err(RunError::MalformedIr(format!(
1414                        "`len` cannot measure {other}"
1415                    )))
1416                }
1417            };
1418            Ok(json!(length))
1419        }
1420        other => Err(RunError::MalformedIr(format!("unknown builtin `{other}`"))),
1421    }
1422}
1423
1424/// The policy subject that governs an effect.
1425///
1426/// Mirrors `PolicySubject::for_effect` in `ingot-types`, but reads from the
1427/// artifact's own vocabulary so the runtime stays independent of the compiler.
1428fn subject_for_effect(effect: &str) -> &str {
1429    match effect {
1430        "secret_access" => "secrets",
1431        other => other,
1432    }
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437    use super::*;
1438
1439    #[test]
1440    fn secret_access_maps_to_the_secrets_subject() {
1441        assert_eq!(subject_for_effect("secret_access"), "secrets");
1442        assert_eq!(subject_for_effect("network"), "network");
1443    }
1444
1445    #[test]
1446    fn len_measures_lists_strings_and_objects() {
1447        assert_eq!(eval_builtin("len", &[json!([1, 2, 3])]).unwrap(), json!(3));
1448        assert_eq!(eval_builtin("len", &[json!("abc")]).unwrap(), json!(3));
1449        assert_eq!(eval_builtin("len", &[json!({"a": 1})]).unwrap(), json!(1));
1450    }
1451
1452    #[test]
1453    fn comparison_operators_work_on_numbers() {
1454        assert_eq!(eval_binary(">", &json!(3), &json!(1)).unwrap(), json!(true));
1455        assert_eq!(
1456            eval_binary("<=", &json!(3), &json!(3)).unwrap(),
1457            json!(true)
1458        );
1459    }
1460
1461    #[test]
1462    fn equality_works_on_any_value() {
1463        assert_eq!(
1464            eval_binary("==", &json!("a"), &json!("a")).unwrap(),
1465            json!(true)
1466        );
1467        assert_eq!(
1468            eval_binary("!=", &json!([1]), &json!([2])).unwrap(),
1469            json!(true)
1470        );
1471    }
1472
1473    #[test]
1474    fn integer_arithmetic_stays_integral() {
1475        assert_eq!(eval_binary("+", &json!(2), &json!(3)).unwrap(), json!(5));
1476    }
1477
1478    #[test]
1479    fn strings_render_without_json_quoting() {
1480        assert_eq!(render_value(&json!("hello"), "string"), "hello");
1481        assert_eq!(render_value(&json!(3), "int"), "3");
1482    }
1483}