Skip to main content

salvor_engine/
lib.rs

1//! The Salvor graph engine: drives a frozen graph document through its nodes,
2//! recording the walk into one durable run log.
3//!
4//! # Where this crate sits, and why it is its own crate
5//!
6//! The engine is deliberately **not** part of `salvor-runtime` (that would drag
7//! the graph document format into the built-in agent loop) and **not** part of
8//! `salvor-graph` (that crate is a pure, IO-free leaf). It sits above both and
9//! composes only their public surfaces: the graph document from `salvor-graph`,
10//! and the durability substrate ([`RunCtx`](salvor_runtime::RunCtx),
11//! [`drive_loop`](salvor_runtime::drive_loop)) from `salvor-runtime`. It reaches
12//! into nothing private. That is a deliberate proof of the runtime's API
13//! guardrail: everything the engine needs, an outside crate could also do.
14//!
15//! # What it drives
16//!
17//! [`run_graph`] opens a run's log with `GraphRunStarted`, walks the nodes in
18//! deterministic topological order (see [`walk`]), and drives each one:
19//!
20//! - an **agent** node runs the built-in agent loop
21//!   ([`drive_loop`](salvor_runtime::drive_loop)) inside the same log, framed by
22//!   `NodeEntered` / `NodeExited`. A node that declares an `output_schema` runs
23//!   the structured form of that loop
24//!   ([`drive_loop_structured`](salvor_runtime::drive_loop_structured)) instead,
25//!   so the node's output is the validated object the schema describes rather
26//!   than the reply text, and downstream expressions can read its fields. The
27//!   resolved agent may declare a schema of its own, in which case the node's
28//!   declaration wins and the agent's is the fallback (see
29//!   [`Agent::output_schema`](salvor_runtime::Agent::output_schema));
30//! - a **tool** node records one tool call through the same write-ahead
31//!   intent/completion machinery the built-in loop uses, honoring the tool's
32//!   effect class. A tool that asks to park the run parks it: a suspension
33//!   through `Suspended` / `Resumed`, a sleep through `SleepStarted` /
34//!   `SleepCompleted`. Either way the node stays entered with no `NodeExited`,
35//!   so a later drive re-enters it and continues from the recorded park. The
36//!   sleep request rides inside the call's own completion, so the call settles
37//!   (and releases any idempotency claim) before the timer starts;
38//! - a **gate** node parks the run through the exact `Suspended` / `Resumed`
39//!   machinery the built-in loop uses for a tool suspension: entering it records
40//!   `NodeEntered`, then `suspend` records the gate's `approval_schema` as the
41//!   suspension schema and the drive returns [`GraphOutcome::Parked`]. A later
42//!   drive over the log (carrying the resume input the existing resume machinery
43//!   appended) passes that input through the gate as its output and continues.
44//!   A gate needs no event kind of its own. A resume input is ENFORCED against
45//!   the gate's `approval_schema` at the accept edge, between the `suspend` and
46//!   the `await_resume` that would record it, so a non-conforming approval is a
47//!   typed refusal that appends nothing and leaves the run parked; a recorded
48//!   `Resumed` is never re-judged on replay (see [`approval`]);
49//! - a **branch** node routes on its input: an expression branch evaluates its
50//!   cases in author order and the first true case wins; a model-decision branch
51//!   drives the node's agent and maps the reply to a case name. Either way the
52//!   chosen case is recorded as `BranchTaken`, the walk follows the like-named
53//!   edge, and every node reachable only through a non-taken case is recorded
54//!   `NodeSkipped`;
55//! - a **map** node fans out over a list. Its `over` reference resolves against
56//!   the routed value to a JSON array (a non-array is a typed
57//!   [`EngineError::MapOverNotAList`] refused before `NodeEntered`); the engine
58//!   records `NodeEntered`, then `MapFannedOut` with the resolved item list, then
59//!   walks the list IN INDEX ORDER, and for each element records
60//!   `MapIterationStarted` (carrying the derived child-run id), runs the body's
61//!   work inline, and records `MapIterationJoined`. The joined output is the
62//!   per-element outputs as a list in index order. Iterations run
63//!   **inline and sequentially** in the parent's own log: the `concurrency` cap is
64//!   accepted (the validator requires it be at least 1) but not honored: a
65//!   deliberate v0.4 choice that costs only wall-clock and changes no event shape,
66//!   so the whole fan-out is proven by the same single-log replay machinery
67//!   already proven for linear and branching graphs. Concurrent child runs are
68//!   not yet supported. A
69//!   `subgraph` body, or a body node that is not an `agent` or `tool`, is a typed
70//!   [`EngineError::UnsupportedMapBody`] refused before `NodeEntered`;
71//! - a **fold** node runs its body up to `max_iterations` times, inline and
72//!   sequentially in the same log. Pass 0's input is the fold's routed value;
73//!   every later pass's input is the previous pass's output, which IS the
74//!   accumulated value: there is no separate accumulator state and no merge rule,
75//!   because the document has no vocabulary for one. A value that is an MCP
76//!   result envelope (an object with a `content` ARRAY and a
77//!   `structuredContent` key) contributes that PAYLOAD as the accumulated
78//!   value, not the object around it: an MCP tool answers with a
79//!   `{content, structuredContent}` envelope, and the payload is the value the
80//!   loop is folding, so the next pass's input, the `stop_when` predicate, a
81//!   `best_by` reference, and the join's own output all read the bare payload
82//!   and a fold expression never reaches through a transport detail. This holds
83//!   for the value ENTERING the fold as much as for one a pass produced, so a
84//!   fold fed by a `tool` node over an edge folds the same shape at pass 0 that
85//!   it folds at pass 3. Any other value (an agent body's structured object, a
86//!   native tool's flat struct, an object that merely carries a field called
87//!   `structuredContent` as data, a string, a list) is carried verbatim. The
88//!   unwrap is DERIVATION, not
89//!   recording: `ToolCallCompleted` still holds the whole envelope, and the
90//!   payload is a pure function of it (see [`unwrap_pass_output`]). Nothing
91//!   outside a fold is touched: an ordinary edge routes a node's recorded output
92//!   verbatim, so a branch expression reading `structuredContent.` still reads
93//!   what it always read. The engine
94//!   records `NodeEntered`, then per pass `FoldIterationStarted`, the body's
95//!   work inline,
96//!   and `FoldIterationJoined`, and stops when the `stop_when` predicate holds
97//!   over the pass just joined or when the bound is reached (there is no third
98//!   cause: nothing stops a fold for "failing to improve"). Reaching the bound
99//!   means what `on_bound` says it means: absent or `join` joins the passes
100//!   anyway, and `fail` is a typed [`EngineError::FoldBoundExceeded`] returned
101//!   from exactly where `FoldConverged` would have been recorded, so the passes
102//!   and their joins stay in the log and no convergence and no `NodeExited`
103//!   land. Otherwise the `join` rule
104//!   picks the value the node produces: `last` takes the final pass, `all` takes
105//!   every pass's value as a list in pass order, and `best_by` is an argmax over
106//!   ALL passes of the reference's value, ordered by the expression language's
107//!   own comparison ([`salvor_graph::expr::compare`]) so the argmax and the
108//!   predicate beside it can never order values differently, keeping the earliest
109//!   pass on a tie. A `best_by` with no comparable candidate in any pass is a
110//!   typed [`EngineError::FoldNoComparableCandidate`] refused before
111//!   `FoldConverged`. The chosen winner and the stop reason are recorded on
112//!   `FoldConverged`, then `NodeExited`. A `subgraph` body, or a body node that
113//!   is not an `agent` or `tool`, is a typed
114//!   [`EngineError::UnsupportedFoldBody`] refused before `NodeEntered`;
115//! - a **delay** node parks the run on a durable timer, the timer counterpart
116//!   of the gate: `NodeEntered`, then `sleep_for` (a recorded clock reading
117//!   followed by `SleepStarted { wake_at }`), then a wait. Before the deadline
118//!   the drive returns [`GraphOutcome::Parked`] with
119//!   [`ParkReason::Sleeping`](salvor_runtime::ParkReason::Sleeping) and no
120//!   `NodeExited`, so a drive that arrives early records nothing and a later
121//!   one re-enters the same node and continues from the recorded sleep. At or
122//!   past the deadline `SleepCompleted` lands, `NodeExited` closes the node,
123//!   and the walk continues with the node's input passed through UNCHANGED: a
124//!   delay moves a run in time, never in value, so its output is its input
125//!   verbatim. It needs no event kind of its own; the sleep pair is the whole
126//!   vocabulary. The wait is a DURATION in the document and the instant is
127//!   derived from the recorded reading, which is what keeps the same document
128//!   runnable more than once (see [`salvor_graph::DelayNode`]).
129//!
130//! A node that is a map's or a fold's body is executed ONLY as that owner's
131//! per-item or per-pass worker; it is never walked independently, so its own
132//! events (a tool call, an agent loop) are recorded inline between the owner's
133//! iteration markers and its node id is never framed with a `NodeEntered` of its
134//! own. That keeps node ids unambiguous in the one log and is why forking INTO a
135//! map iteration or a fold pass is refused: neither is a node boundary (see
136//! [`plan_fork`]).
137//!
138//! After the last node the engine records the single terminal `RunCompleted`.
139//! It records no terminal for a refusal: refuse-before-record is what keeps the
140//! log free of events past one. Whether a refused run is DEAD or merely stuck is
141//! the driver's call, and [`EngineError::is_permanent`] is how the engine tells
142//! it apart; [`record_permanent_refusal`] is the append the drivers make when
143//! the answer is dead. There is no ambient clock or randomness in any decision: everything the
144//! engine feeds forward (the walk order, each node's input, the branch route, a
145//! map's resolved item list and its per-iteration child ids, a fold's stop
146//! decision, its winner and its recorded reason, an idempotent tool's
147//! idempotency key) is a pure function of the document or of values the `RunCtx`
148//! recorded, so a second drive over the recorded log replays with no live calls
149//! and produces a byte-identical log. A map iteration's child-run id is
150//! `sha256:` over the parent run id, the node id, and the index (see
151//! [`map_child_run_id`]): pure recorded data, so replay reconstructs the
152//! identical id without storing anything extra. The idempotency
153//! key is derived from the call's position in the graph (graph hash, node id,
154//! call index) rather than from drawn randomness, which is what lets a FORK of a
155//! run re-walk a segment and present the same key its origin recorded. See
156//! [`fork_safe_idempotency_key`] and the `salvor-server` fork endpoint.
157//!
158//! # Data flow
159//!
160//! Each node's output flows to its successors along the edges, and a node's
161//! input is the recorded output of the live inbound edge that reaches it (the
162//! graph input for an entry node with no inbound edge). A branch passes its
163//! routed value through unchanged to the taken case's edge; the decision only
164//! selects the route, never the data. A tool node's `input` references are still
165//! not resolved yet; the upstream output is the downstream input
166//! verbatim. When more than one live inbound edge reaches a node, the one whose
167//! source id is smallest wins, so the merge is a pure function of the document.
168//!
169//! # Resolving agents and tools
170//!
171//! A node names its agent by hash and its tool by name; the engine turns those
172//! into executables through the [`AgentResolver`] and [`ToolResolver`] traits
173//! the caller supplies. Tests inject maps; the server wires its own
174//! registries in separately. Keeping resolution behind a trait is what lets the engine stay
175//! ignorant of where agents and tools actually come from.
176
177#![warn(missing_docs)]
178
179pub mod approval;
180mod error;
181pub mod fork;
182mod walk;
183
184use std::cmp::Ordering;
185use std::collections::{HashMap, HashSet};
186
187use salvor_core::{Effect, RunId};
188use salvor_graph::expr::{Expr, Reference};
189use salvor_graph::{
190    AgentNode, BranchCondition, BranchNode, DelayNode, Edge, FoldBody, FoldJoin, FoldNode,
191    GateNode, Graph, MapBody, MapNode, Node, OnBound,
192};
193use salvor_runtime::{
194    Agent, LoopOutcome, ParkReason, Resumption, RunCtx, ToolCallResult, Waking, drive_loop,
195    drive_loop_structured, hash_value, slept_output,
196};
197use salvor_tools::DynTool;
198use serde_json::{Value, json};
199
200pub use approval::{ApprovalViolation, approval_violations, parked_gate};
201pub use error::EngineError;
202pub use fork::{ForkError, ForkPlan, WriteHazard, plan_fork};
203
204/// Resolves an `agent` node's declared hash to the [`Agent`] that executes it.
205///
206/// A small trait, not a fixed type, so a test can inject a map while the server
207/// injects its agent registry. A [`HashMap<String, Agent>`](std::collections::HashMap)
208/// implements it out of the box.
209pub trait AgentResolver {
210    /// The agent registered under `agent_hash`, or `None` if none is.
211    fn resolve_agent(&self, agent_hash: &str) -> Option<&Agent>;
212}
213
214/// Resolves a `tool` node's declared name to the [`DynTool`] that executes it.
215///
216/// The tool counterpart of [`AgentResolver`]. A
217/// [`HashMap<String, Box<dyn DynTool>>`](std::collections::HashMap) implements
218/// it out of the box.
219pub trait ToolResolver {
220    /// The tool registered under `name`, or `None` if none is.
221    fn resolve_tool(&self, name: &str) -> Option<&dyn DynTool>;
222}
223
224impl AgentResolver for HashMap<String, Agent> {
225    fn resolve_agent(&self, agent_hash: &str) -> Option<&Agent> {
226        self.get(agent_hash)
227    }
228}
229
230impl ToolResolver for HashMap<String, Box<dyn DynTool>> {
231    fn resolve_tool(&self, name: &str) -> Option<&dyn DynTool> {
232        self.get(name).map(AsRef::as_ref)
233    }
234}
235
236/// How a graph drive ended.
237#[derive(Debug)]
238pub enum GraphOutcome {
239    /// The graph ran to completion; this is the final output the terminal
240    /// `RunCompleted` recorded.
241    Completed {
242        /// The graph run's final output (the last node's output).
243        output: Value,
244    },
245    /// A node parked the run durably (an agent's budget crossing or a tool
246    /// suspension). The run survives restarts; resume it through the runtime's
247    /// resume path, then drive the graph again to continue.
248    Parked {
249        /// The node that parked.
250        node: String,
251        /// Why it parked.
252        reason: ParkReason,
253    },
254}
255
256/// Computes a graph document's content hash: `sha256:` over its canonical JSON,
257/// the exact string recorded in `GraphRunStarted`.
258///
259/// Reuses `salvor-runtime`'s canonical hashing (the same story behind
260/// `agent_def_hash` and `request_hash`), so a graph run's `graph_hash` is
261/// reproducible and matches whatever a control plane computes for the same
262/// document.
263///
264/// # Errors
265///
266/// [`EngineError::GraphEncode`] if the document cannot be serialized (it always
267/// can; the edge is kept honest rather than panicking).
268pub fn graph_hash(graph: &Graph) -> Result<String, EngineError> {
269    let value = serde_json::to_value(graph).map_err(EngineError::GraphEncode)?;
270    Ok(hash_value(&value))
271}
272
273/// Drives `graph` to completion (or a park) over `ctx`, recording the walk into
274/// the run's log.
275///
276/// The log opens with `GraphRunStarted { graph_hash }`, each node contributes
277/// `NodeEntered` … its own events … `NodeExited`, and the run closes with one
278/// `RunCompleted`. See the crate docs for the node handling and determinism
279/// guarantees. Fresh, recovering, or replaying is entirely the `ctx`'s
280/// business: the engine issues the same sequence of `RunCtx` calls either way,
281/// which is what makes a second drive over the recorded log a byte-identical,
282/// zero-live-call replay.
283///
284/// # Errors
285///
286/// [`EngineError::MapOverNotAList`] when a map node's `over` reference does not
287/// resolve to a list, and [`EngineError::UnsupportedMapBody`] for a `subgraph` or
288/// non-`agent`/`tool` body (both before the map's `NodeEntered` is recorded);
289/// [`EngineError::UnsupportedFoldBody`] for a fold node's `subgraph` or
290/// non-`agent`/`tool` body (before the fold's `NodeEntered`), and
291/// [`EngineError::FoldNoComparableCandidate`] when a `best_by` join finds no
292/// comparable value in any pass, and [`EngineError::FoldBoundExceeded`] when a
293/// fold declaring `on_bound: fail` reaches its bound with `stop_when` still
294/// unsatisfied (both after the passes ran, before `FoldConverged`);
295/// [`EngineError::NoBranchCaseMatched`] when an expression branch
296/// matches no case (also before its `NodeEntered`);
297/// [`EngineError::BranchDecisionUnmatched`] when a model-decision branch's agent
298/// names no case (after its `NodeEntered`, since the model had to run);
299/// [`EngineError::UnknownAgent`] / [`EngineError::UnknownTool`] when a resolver
300/// cannot supply a node's executable; [`EngineError::MalformedGraph`] when the
301/// topology is not a DAG (or, unreachable in practice, a branch condition the
302/// validator accepted fails to parse here, or a `delay` node's wait is zero or
303/// out of range, refused before that node's `NodeEntered`); [`EngineError::ToolFailed`] when a
304/// tool call fails; [`EngineError::Runtime`] for any replay divergence,
305/// reconciliation refusal, provider, or store error.
306pub async fn run_graph(
307    ctx: &mut RunCtx,
308    graph: &Graph,
309    input: &Value,
310    agents: &impl AgentResolver,
311    tools: &impl ToolResolver,
312) -> Result<GraphOutcome, EngineError> {
313    let hash = graph_hash(graph)?;
314    // The recorded input always wins on replay; `begin_graph` returns it.
315    let graph_input = ctx.begin_graph(&hash, input).await?;
316
317    // Topology and routing state, all keyed on ids that borrow the document.
318    let by_id: HashMap<&str, &Node> = graph.nodes.iter().map(|n| (n.id(), n)).collect();
319    let mut inbound: HashMap<&str, Vec<&Edge>> = HashMap::new();
320    for edge in &graph.edges {
321        inbound.entry(edge.to.as_str()).or_default().push(edge);
322    }
323    // Branch conditions are parsed ONCE here (the validator already guarantees
324    // they parse; a failure now is a MalformedGraph unreachable in practice).
325    let branches = parse_branches(graph)?;
326    // Every fold's `stop_when` (and its `best_by` reference) is parsed ONCE
327    // here, on the same terms the branch conditions are.
328    let folds = parse_folds(graph)?;
329    // The ids of every node used as a `map` or `fold` body: they are the
330    // per-item workers of their map and the per-pass workers of their fold, and
331    // are executed ONLY inside that owner's loop, never walked independently, so
332    // their events stay unambiguous inside the one log.
333    let body_targets: HashSet<&str> = graph
334        .nodes
335        .iter()
336        .filter_map(|node| match node {
337            Node::Map(map) => match &map.body {
338                MapBody::Node(target) => Some(target.as_str()),
339                MapBody::Subgraph(_) => None,
340            },
341            Node::Fold(fold) => match &fold.body {
342                FoldBody::Node(target) => Some(target.as_str()),
343                FoldBody::Subgraph(_) => None,
344            },
345            _ => None,
346        })
347        .collect();
348
349    // What each executed node produced, which nodes were skipped, and which case
350    // each branch fired: the pure state the routing reads. `last_output` threads
351    // the terminal output, seeded with the graph input so an empty graph still
352    // completes with it (matching a linear graph with no nodes at all).
353    let mut outputs: HashMap<&str, Value> = HashMap::new();
354    let mut skipped: HashSet<&str> = HashSet::new();
355    let mut branch_case: HashMap<&str, String> = HashMap::new();
356    let mut last_output = graph_input.clone();
357
358    for node in walk::walk_order(graph)? {
359        let id = node.id();
360        // A node used as a map or fold body is not walked independently: it runs
361        // only as its owner's per-item or per-pass worker, inline in the loop
362        // below. Nothing is recorded for it here: it is body-owned, not
363        // "skipped".
364        if body_targets.contains(id) {
365            continue;
366        }
367        // A node with no live inbound edge was routed past: record the skip (its
368        // sole marker) and move on. Predecessors are visited first in topological
369        // order, so their skip/branch state is already known here.
370        let Some(node_input) = select_input(
371            id,
372            &inbound,
373            &by_id,
374            &branch_case,
375            &skipped,
376            &outputs,
377            &graph_input,
378        ) else {
379            ctx.node_skipped(id, SKIP_REASON).await?;
380            skipped.insert(id);
381            continue;
382        };
383
384        match node {
385            Node::Agent(agent_node) => {
386                let agent = agents
387                    .resolve_agent(&agent_node.agent_hash)
388                    .ok_or_else(|| EngineError::UnknownAgent {
389                        node: agent_node.id.clone(),
390                        agent_hash: agent_node.agent_hash.clone(),
391                    })?;
392                ctx.node_entered(id).await?;
393                // The agent loop runs inside this same log via the runtime's
394                // begin/drive_loop split: no second run head, and it returns the
395                // output without recording a terminal (the engine owns that).
396                match drive_agent_node(ctx, agent_node, agent, &node_input).await? {
397                    LoopOutcome::Completed(output) => {
398                        ctx.node_exited(id).await?;
399                        last_output = output.clone();
400                        outputs.insert(id, output);
401                    }
402                    LoopOutcome::Parked(reason) => {
403                        return Ok(GraphOutcome::Parked {
404                            node: agent_node.id.clone(),
405                            reason,
406                        });
407                    }
408                }
409            }
410            Node::Tool(tool_node) => {
411                let tool = tools.resolve_tool(&tool_node.tool).ok_or_else(|| {
412                    EngineError::UnknownTool {
413                        node: tool_node.id.clone(),
414                        tool: tool_node.tool.clone(),
415                    }
416                })?;
417                ctx.node_entered(id).await?;
418                // An idempotent tool's key is a PURE function of WHERE the call
419                // sits in the graph (the graph hash, the node id, the call index
420                // within the node), not of drawn randomness. That is what makes it
421                // fork-safe: a fork re-walks the segment from its fork node and
422                // re-executes the idempotent calls in it live, and this derivation
423                // hands each of them the IDENTICAL key its origin recorded, so the
424                // provider collapses the duplicate. It also leaves `Effect::Write`
425                // as the sole class a fork must have acknowledged. Read and write
426                // tools carry no key (see the built-in loop).
427                let idempotency_key = match tool.effect() {
428                    Effect::Idempotent => Some(fork_safe_idempotency_key(&hash, id, 0)),
429                    Effect::Read | Effect::Write => None,
430                };
431                match ctx
432                    .tool_call(tool, &node_input, idempotency_key.as_deref())
433                    .await?
434                {
435                    ToolCallResult::Output(output) => {
436                        ctx.node_exited(id).await?;
437                        last_output = output.clone();
438                        outputs.insert(id, output);
439                    }
440                    ToolCallResult::Failed(failure) => {
441                        return Err(EngineError::ToolFailed {
442                            node: tool_node.id.clone(),
443                            message: failure.message,
444                        });
445                    }
446                    ToolCallResult::Suspended(suspension) => {
447                        // Whatever the tool said it waits on is recorded and
448                        // reported. A node parked on a webhook is not a gate
449                        // and must not read as one.
450                        ctx.suspend_with_kind(
451                            &suspension.reason,
452                            &suspension.input_schema,
453                            suspension.kind,
454                        )
455                        .await?;
456                        match ctx.await_resume().await? {
457                            Resumption::Parked => {
458                                return Ok(GraphOutcome::Parked {
459                                    node: tool_node.id.clone(),
460                                    reason: ParkReason::Suspended {
461                                        reason: suspension.reason,
462                                        input_schema: suspension.input_schema,
463                                        kind: suspension.kind,
464                                    },
465                                });
466                            }
467                            Resumption::Resumed(resume_input) => {
468                                // The recorded resume input is the tool's answer.
469                                ctx.node_exited(id).await?;
470                                last_output = resume_input.clone();
471                                outputs.insert(id, resume_input);
472                            }
473                        }
474                    }
475                    // The timer park, the same shape as the suspension above:
476                    // the node stays entered with no `NodeExited`, so a later
477                    // drive re-enters it and continues from the recorded sleep.
478                    // The call settled before the sleep started (its completion
479                    // carried the request), so a node asleep for a week holds
480                    // no idempotency claim.
481                    ToolCallResult::Sleeping(sleep) => {
482                        ctx.sleep_until(sleep.wake_at).await?;
483                        match ctx.await_wake().await? {
484                            Waking::Asleep { wake_at } => {
485                                return Ok(GraphOutcome::Parked {
486                                    node: tool_node.id.clone(),
487                                    reason: ParkReason::Sleeping { wake_at },
488                                });
489                            }
490                            Waking::Woken => {
491                                // The tool named a deadline instead of a value,
492                                // so the node's output is derived from the wake
493                                // instant its completion recorded: pure, and
494                                // identical on every replay.
495                                let output = slept_output(sleep.wake_at);
496                                ctx.node_exited(id).await?;
497                                last_output = output.clone();
498                                outputs.insert(id, output);
499                            }
500                        }
501                    }
502                }
503            }
504            // A gate parks through the exact suspension machinery a tool uses:
505            // NodeEntered, then `suspend` recording the gate's approval schema,
506            // then a park. A later drive over the resumed log passes the resume
507            // input through as the gate's output. No gate-specific event kind.
508            Node::Gate(gate) => {
509                ctx.node_entered(id).await?;
510                let reason = gate_reason(gate);
511                ctx.suspend(&reason, &gate.approval_schema).await?;
512                // THE ACCEPT EDGE. Right here, and nowhere later, is where a
513                // resume input may be judged: the gate's `Suspended` is on
514                // disk, and the next line can append a `Resumed`. Refusing
515                // before that append is what keeps the refusal free: nothing
516                // lands in the log and the run stays parked at this gate,
517                // waiting for an approval that conforms.
518                //
519                // The guard is `is_replaying()`. When history remains, the next
520                // event is a RECORDED `Resumed`, and a recorded `Resumed` is
521                // never re-judged: replay trusts what was written. That is
522                // load-bearing rather than an optimization. If replay
523                // re-validated, a stricter validator (or a new `jsonschema`
524                // release) would turn logs that replayed yesterday into
525                // refusals today, and a durable log that stops replaying is not
526                // durable. So: check what has not been written, trust what has.
527                if !ctx.is_replaying()
528                    && let Some(input) = ctx.staged_resume_input()
529                {
530                    let violations = approval_violations(input, &gate.approval_schema);
531                    if !violations.is_empty() {
532                        return Err(EngineError::ApprovalSchemaViolation {
533                            node: gate.id.clone(),
534                            violations,
535                        });
536                    }
537                }
538                match ctx.await_resume().await? {
539                    Resumption::Parked => {
540                        return Ok(GraphOutcome::Parked {
541                            node: gate.id.clone(),
542                            reason: ParkReason::Suspended {
543                                reason,
544                                input_schema: gate.approval_schema.clone(),
545                                // A gate is the human park by definition, so
546                                // it names no discriminator.
547                                kind: None,
548                            },
549                        });
550                    }
551                    Resumption::Resumed(resume_input) => {
552                        ctx.node_exited(id).await?;
553                        last_output = resume_input.clone();
554                        outputs.insert(id, resume_input);
555                    }
556                }
557            }
558            // A delay parks on a timer the way a gate parks on a person: enter
559            // the node, park, and leave no `NodeExited` behind, so a later
560            // drive re-enters it and continues from the recorded sleep. No
561            // delay-specific event kind: `SleepStarted` / `SleepCompleted` is
562            // the whole vocabulary, exactly as `Suspended` / `Resumed` is the
563            // gate's.
564            Node::Delay(delay) => {
565                // Refuse-before-record: the declared wait becomes a duration
566                // here, ahead of `NodeEntered`, so a document the validator
567                // would have refused leaves nothing in the log.
568                let duration = delay_duration(delay)?;
569                ctx.node_entered(id).await?;
570                // `sleep_for` is `now()` then `sleep_until`: the clock reading
571                // lands in the log as a `NowObserved` and the wake instant is
572                // derived from it, so the instant is a pure function of
573                // recorded data and every later drive derives the identical
574                // one. A wake instant baked into the document could not make
575                // that claim on a second run, which is why the field is a
576                // duration.
577                ctx.sleep_for(duration).await?;
578                match ctx.await_wake().await? {
579                    Waking::Asleep { wake_at } => {
580                        return Ok(GraphOutcome::Parked {
581                            node: delay.id.clone(),
582                            reason: ParkReason::Sleeping { wake_at },
583                        });
584                    }
585                    // A delay transforms nothing, so its output is its input
586                    // verbatim, the same pass-through a branch performs: the
587                    // node moves the run in time, never in value.
588                    Waking::Woken => {
589                        ctx.node_exited(id).await?;
590                        last_output = node_input.clone();
591                        outputs.insert(id, node_input);
592                    }
593                }
594            }
595            Node::Branch(branch) => {
596                // A branch is a pure router: whichever case fires, the routed
597                // value passes through unchanged to the taken edge.
598                let cases = branches.get(id).expect("every branch node is parsed");
599                let chosen: String = match &branch.agent_hash {
600                    // Expression branch: choose purely, so a no-match refuses
601                    // before NodeEntered and nothing lands past the refusal.
602                    None => {
603                        let case = choose_expression_case(id, cases, &node_input)?;
604                        ctx.node_entered(id).await?;
605                        case.to_owned()
606                    }
607                    // Model-decision branch: the agent must run first, so its
608                    // NodeEntered and model events precede the mapping (and the
609                    // BranchDecisionUnmatched refusal, if the reply names no case).
610                    Some(agent_hash) => {
611                        let agent = agents.resolve_agent(agent_hash).ok_or_else(|| {
612                            EngineError::UnknownAgent {
613                                node: branch.id.clone(),
614                                agent_hash: agent_hash.clone(),
615                            }
616                        })?;
617                        ctx.node_entered(id).await?;
618                        let reply = match drive_loop(ctx, agent, &node_input).await? {
619                            LoopOutcome::Completed(output) => output,
620                            LoopOutcome::Parked(reason) => {
621                                return Ok(GraphOutcome::Parked {
622                                    node: branch.id.clone(),
623                                    reason,
624                                });
625                            }
626                        };
627                        match_decision(branch, &reply)?.to_owned()
628                    }
629                };
630                ctx.branch_taken(id, &chosen).await?;
631                ctx.node_exited(id).await?;
632                branch_case.insert(id, chosen);
633                last_output = node_input.clone();
634                outputs.insert(id, node_input);
635            }
636            Node::Map(map_node) => {
637                match drive_map(ctx, map_node, &node_input, &by_id, agents, tools, &hash).await? {
638                    MapOutcome::Joined(output) => {
639                        last_output = output.clone();
640                        outputs.insert(id, output);
641                    }
642                    MapOutcome::Parked { node, reason } => {
643                        return Ok(GraphOutcome::Parked { node, reason });
644                    }
645                }
646            }
647            // A fold's parsed plan carries the node itself, so the drive takes
648            // one argument for both.
649            Node::Fold(_) => {
650                let plan = folds.get(id).expect("every fold node is parsed");
651                match drive_fold(ctx, plan, &node_input, &by_id, agents, tools, &hash).await? {
652                    FoldOutcome::Converged(output) => {
653                        last_output = output.clone();
654                        outputs.insert(id, output);
655                    }
656                    FoldOutcome::Parked { node, reason } => {
657                        return Ok(GraphOutcome::Parked { node, reason });
658                    }
659                }
660            }
661        }
662    }
663
664    ctx.complete_run(&last_output).await?;
665    Ok(GraphOutcome::Completed {
666        output: last_output,
667    })
668}
669
670/// Records the terminal `RunFailed` a PERMANENT [`run_graph`] refusal deserves,
671/// so a dead run stops masquerading as a running one.
672///
673/// [`run_graph`] itself never writes a terminal for a refusal, and that is
674/// deliberate: refuse-before-record is what keeps the log free of events past a
675/// refusal, and the engine cannot know whether its caller intends to re-drive.
676/// The DRIVER does know, and it is the driver that owns the run's disposition.
677/// So the drivers (the CLI's `graph run`, `resume`, and `graph fork` paths, and
678/// the server's `drive_graph` task) call this with the error they just received
679/// and THE SAME `ctx` that produced it. The same one matters: the append claims
680/// the position that `ctx`'s cursor stands on, which is the position after
681/// everything the refused drive replayed or wrote. A fresh `RunCtx` over the
682/// same log stands at position zero and would diverge rather than append, which
683/// is the machinery refusing to write a terminal onto a run it has not read.
684///
685/// Only a permanent error is recorded: one that
686/// [`EngineError::is_permanent`] calls a pure function of the frozen document
687/// and the recorded log, so re-driving reproduces it forever. A transient error
688/// is left exactly as it was, and the run stays recoverable. Returns whether a
689/// terminal was recorded, so a caller can say so in its own voice.
690///
691/// # Ordering, and the kill between the refusal and this append
692///
693/// The append goes through [`RunCtx::fail_run`](salvor_runtime::RunCtx::fail_run),
694/// which is the same persist discipline every other event uses: the cursor
695/// claims the next sequence and the envelope is durable before this returns.
696/// That is also the "only when the log holds no terminal" guard, and it needs no
697/// second read of the store. A log whose recorded next event is the identical
698/// `RunFailed` (a driver that already did this) REPLAYS it and appends nothing;
699/// a log holding a different terminal is a divergence the caller surfaces
700/// rather than overwrites.
701///
702/// A `kill -9` landing between the refusal and this append therefore leaves a
703/// log with no terminal at all, ending at whatever the refusal was recorded
704/// past (for a fold, its last `FoldIterationJoined`). That log is not corrupt
705/// and not stuck: the next drive replays it, re-derives the SAME permanent
706/// refusal from the same recorded values, and this appends the `RunFailed`
707/// then. Nothing re-executes, because everything before the refusal is history.
708/// The window is a delay in the status an operator reads, never a divergence.
709///
710/// # Errors
711///
712/// [`RuntimeError`](salvor_runtime::RuntimeError) when the append does not
713/// persist, or when the log already holds a different terminal. Callers report
714/// the ORIGINAL engine refusal in that case: it is the real news, and losing
715/// the terminal only means the run reads as recoverable when it is not.
716pub async fn record_permanent_refusal(
717    ctx: &mut RunCtx,
718    error: &EngineError,
719) -> Result<bool, salvor_runtime::RuntimeError> {
720    if !error.is_permanent() {
721        return Ok(false);
722    }
723    ctx.fail_run(&error.to_string()).await?;
724    Ok(true)
725}
726
727/// The wait a `delay` node declares, as a duration the runtime can sleep for.
728///
729/// Two refusals, both pure functions of the frozen document and both raised
730/// BEFORE the node's `NodeEntered`, so a document that trips either leaves
731/// nothing in the log. A zero wait is the one
732/// [`salvor_graph::validate`] already reports as `NonPositiveDelay`; this
733/// re-checks it defensively for the same reason [`drive_fold`] re-checks a
734/// fold's bound, because a validator is a submit-time gate and the engine can
735/// be handed a document by some other route. A wait past `i64::MAX` seconds
736/// cannot be a [`time::Duration`] at all; it is astronomically out of range
737/// rather than merely long, and the alternative to naming it is a panic in a
738/// conversion.
739fn delay_duration(delay: &DelayNode) -> Result<time::Duration, EngineError> {
740    if delay.seconds < 1 {
741        return Err(EngineError::MalformedGraph {
742            detail: format!(
743                "delay node `{}`: seconds must be at least 1, found {}",
744                delay.id, delay.seconds
745            ),
746        });
747    }
748    let seconds = i64::try_from(delay.seconds).map_err(|_| EngineError::MalformedGraph {
749        detail: format!(
750            "delay node `{}`: a wait of {} seconds is outside the representable range",
751            delay.id, delay.seconds
752        ),
753    })?;
754    Ok(time::Duration::seconds(seconds))
755}
756
757/// How driving one map node's fan-out ended.
758enum MapOutcome {
759    /// Every iteration joined; the map's output is the per-index outputs as a
760    /// JSON array in index order.
761    Joined(Value),
762    /// An iteration parked the run (an `agent` body's budget crossing or a `tool`
763    /// body's suspension), propagated up as a graph park at the map node.
764    Parked {
765        /// The map node that parked.
766        node: String,
767        /// Why it parked.
768        reason: ParkReason,
769    },
770}
771
772/// Drives one map node's fan-out inline and sequentially into the parent log.
773///
774/// Refuses an unsupported body form or a non-list `over` **before** recording the
775/// map's `NodeEntered`, so nothing lands in the log past such a refusal. Then
776/// records `NodeEntered`, `MapFannedOut` with the resolved item list, and for each
777/// element in INDEX ORDER records `MapIterationStarted` (with the derived
778/// child-run id), runs the body's work inline, and records `MapIterationJoined`.
779/// The `concurrency` cap is accepted but not honored: iterations run
780/// one after another, which is why the whole fan-out is a plain single-log replay.
781async fn drive_map(
782    ctx: &mut RunCtx,
783    map_node: &MapNode,
784    routed: &Value,
785    by_id: &HashMap<&str, &Node>,
786    agents: &impl AgentResolver,
787    tools: &impl ToolResolver,
788    graph_hash: &str,
789) -> Result<MapOutcome, EngineError> {
790    let node_id = map_node.id.as_str();
791
792    // Resolve the body up front so an unsupported body form refuses BEFORE the
793    // map's NodeEntered is recorded.
794    let body: &Node = match &map_node.body {
795        MapBody::Node(target) => {
796            let body_node =
797                by_id
798                    .get(target.as_str())
799                    .copied()
800                    .ok_or_else(|| EngineError::MalformedGraph {
801                        detail: format!("map node `{node_id}`: body names unknown node `{target}`"),
802                    })?;
803            match body_node {
804                Node::Agent(_) | Node::Tool(_) => body_node,
805                other => {
806                    return Err(EngineError::UnsupportedMapBody {
807                        node: node_id.to_owned(),
808                        detail: format!(
809                            "a `{}` body node cannot be a per-item worker; only `agent` and `tool` bodies run",
810                            other.kind_name()
811                        ),
812                    });
813                }
814            }
815        }
816        MapBody::Subgraph(_) => {
817            return Err(EngineError::UnsupportedMapBody {
818                node: node_id.to_owned(),
819                detail: "an embedded `subgraph` body is not executed yet".to_owned(),
820            });
821        }
822    };
823
824    // Resolve `over` against the routed value; a non-array (including a missing
825    // path) is a typed refusal BEFORE NodeEntered.
826    let items = resolve_over(node_id, &map_node.over, routed)?;
827
828    ctx.node_entered(node_id).await?;
829    ctx.map_fanned_out(node_id, &Value::Array(items.clone()))
830        .await?;
831
832    let mut joined: Vec<Value> = Vec::with_capacity(items.len());
833    for (position, item) in items.iter().enumerate() {
834        let index = position as u64;
835        let child_run = map_child_run_id(ctx.run_id(), node_id, index);
836        ctx.map_iteration_started(node_id, index, &child_run)
837            .await?;
838        let call = BodyCall {
839            owner: BodyOwner::Map,
840            graph_hash,
841            node_id,
842            index,
843        };
844        match run_body(ctx, body, item, agents, tools, call).await? {
845            IterationOutcome::Output(output) => joined.push(output),
846            IterationOutcome::Parked(reason) => {
847                return Ok(MapOutcome::Parked {
848                    node: node_id.to_owned(),
849                    reason,
850                });
851            }
852        }
853        // Joins are recorded strictly in index order, never completion order.
854        ctx.map_iteration_joined(node_id, index).await?;
855    }
856    ctx.node_exited(node_id).await?;
857    Ok(MapOutcome::Joined(Value::Array(joined)))
858}
859
860/// How one map iteration's or fold pass's body work ended.
861enum IterationOutcome {
862    /// The body produced this output for the iteration or pass.
863    Output(Value),
864    /// The body parked (an agent budget crossing or a tool suspension).
865    Parked(ParkReason),
866}
867
868/// Which node kind owns an inline body run. Carried only so the unsupported-body
869/// error the shared runner returns names the right kind; the two owners run the
870/// body identically.
871#[derive(Clone, Copy)]
872enum BodyOwner {
873    Map,
874    Fold,
875}
876
877/// Where one map iteration's or fold pass's tool call sits, for deriving its
878/// fork-safe idempotency key: the graph hash, the owning node's id, and the
879/// iteration or pass index.
880struct BodyCall<'a> {
881    /// Which node kind owns this run.
882    owner: BodyOwner,
883    /// The graph document hash.
884    graph_hash: &'a str,
885    /// The owning map or fold node id (the "node" this call belongs to).
886    node_id: &'a str,
887    /// The zero-based iteration or pass index (the call index within the node).
888    index: u64,
889}
890
891impl BodyCall<'_> {
892    /// The refusal for a body node kind that cannot be a worker, named for the
893    /// owner. Both callers validate the body kind before the loop, so this is
894    /// only ever built on a path the caller already proved unreachable.
895    fn unsupported_body(&self, detail: String) -> EngineError {
896        match self.owner {
897            BodyOwner::Map => EngineError::UnsupportedMapBody {
898                node: self.node_id.to_owned(),
899                detail,
900            },
901            BodyOwner::Fold => EngineError::UnsupportedFoldBody {
902                node: self.node_id.to_owned(),
903                detail,
904            },
905        }
906    }
907}
908
909/// Drives one `agent` node's loop, wherever it sits: walked as a node of its
910/// own, or run inline as a map's or fold's per-item worker.
911///
912/// A declared `output_schema` is the whole difference between the two loops.
913/// With one, the runtime offers the model its answer tool and validates the
914/// answer against the schema, so this node's output is that object and a
915/// downstream expression can read a field of it; without one, the output is the
916/// reply text as before.
917///
918/// Two places can declare it, and the rule is **node wins, else the agent's
919/// own**. The node's `output_schema` is the graph author's statement about what
920/// this position in this document needs, made with the whole document in view;
921/// the agent's is the agent author's statement about what the agent always
922/// produces, made without knowing which graph would call it. The more specific
923/// declaration is the more informed one, so it takes the node's when there is
924/// one and falls back to the agent's when there is not. A node that declares
925/// nothing and an agent that declares nothing stay on the plain text loop, as
926/// every graph did before either declaration existed.
927async fn drive_agent_node(
928    ctx: &mut RunCtx,
929    node: &AgentNode,
930    agent: &Agent,
931    input: &Value,
932) -> Result<LoopOutcome, EngineError> {
933    let schema = node
934        .output_schema
935        .as_ref()
936        .or_else(|| agent.output_schema());
937    let outcome = match schema {
938        Some(schema) => drive_loop_structured(ctx, agent, input, schema).await?,
939        None => drive_loop(ctx, agent, input).await?,
940    };
941    Ok(outcome)
942}
943
944/// Runs one map iteration's or fold pass's body work inline: the referenced
945/// `agent` or `tool` node's work with `item` as its input, recorded in the parent
946/// log WITHOUT a `NodeEntered` frame of its own (the owner's markers bracket it
947/// instead). The body kind was already validated as `agent` or `tool` by
948/// [`drive_map`] or [`drive_fold`].
949async fn run_body(
950    ctx: &mut RunCtx,
951    body: &Node,
952    item: &Value,
953    agents: &impl AgentResolver,
954    tools: &impl ToolResolver,
955    call: BodyCall<'_>,
956) -> Result<IterationOutcome, EngineError> {
957    match body {
958        Node::Agent(agent_node) => {
959            let agent = agents
960                .resolve_agent(&agent_node.agent_hash)
961                .ok_or_else(|| EngineError::UnknownAgent {
962                    node: agent_node.id.clone(),
963                    agent_hash: agent_node.agent_hash.clone(),
964                })?;
965            match drive_agent_node(ctx, agent_node, agent, item).await? {
966                LoopOutcome::Completed(output) => Ok(IterationOutcome::Output(output)),
967                LoopOutcome::Parked(reason) => Ok(IterationOutcome::Parked(reason)),
968            }
969        }
970        Node::Tool(tool_node) => {
971            let tool =
972                tools
973                    .resolve_tool(&tool_node.tool)
974                    .ok_or_else(|| EngineError::UnknownTool {
975                        node: tool_node.id.clone(),
976                        tool: tool_node.tool.clone(),
977                    })?;
978            // Each iteration or pass is a distinct call of the OWNING node, so
979            // its idempotent key is derived from that node's id and the index:
980            // the "several calls within one node" case
981            // `fork_safe_idempotency_key`'s call-index parameter exists for. A
982            // fork re-walking the loop presents each call's identical key;
983            // Read/Write carry none.
984            let idempotency_key = match tool.effect() {
985                Effect::Idempotent => Some(fork_safe_idempotency_key(
986                    call.graph_hash,
987                    call.node_id,
988                    call.index,
989                )),
990                Effect::Read | Effect::Write => None,
991            };
992            match ctx
993                .tool_call(tool, item, idempotency_key.as_deref())
994                .await?
995            {
996                ToolCallResult::Output(output) => Ok(IterationOutcome::Output(output)),
997                ToolCallResult::Failed(failure) => Err(EngineError::ToolFailed {
998                    node: tool_node.id.clone(),
999                    message: failure.message,
1000                }),
1001                ToolCallResult::Suspended(suspension) => {
1002                    ctx.suspend_with_kind(
1003                        &suspension.reason,
1004                        &suspension.input_schema,
1005                        suspension.kind,
1006                    )
1007                    .await?;
1008                    match ctx.await_resume().await? {
1009                        Resumption::Parked => Ok(IterationOutcome::Parked(ParkReason::Suspended {
1010                            reason: suspension.reason,
1011                            input_schema: suspension.input_schema,
1012                            kind: suspension.kind,
1013                        })),
1014                        Resumption::Resumed(resume_input) => {
1015                            Ok(IterationOutcome::Output(resume_input))
1016                        }
1017                    }
1018                }
1019                // The timer park. No join is recorded for a parked iteration or
1020                // pass, so a later drive re-enters this same one and continues
1021                // from the recorded sleep, exactly as a suspension does.
1022                ToolCallResult::Sleeping(sleep) => {
1023                    ctx.sleep_until(sleep.wake_at).await?;
1024                    match ctx.await_wake().await? {
1025                        Waking::Asleep { wake_at } => {
1026                            Ok(IterationOutcome::Parked(ParkReason::Sleeping { wake_at }))
1027                        }
1028                        Waking::Woken => Ok(IterationOutcome::Output(slept_output(sleep.wake_at))),
1029                    }
1030                }
1031            }
1032        }
1033        // The caller validated the body kind as agent or tool before the loop.
1034        other => Err(call.unsupported_body(format!(
1035            "a `{}` body node cannot be a worker",
1036            other.kind_name()
1037        ))),
1038    }
1039}
1040
1041/// How driving one fold node's loop ended.
1042enum FoldOutcome {
1043    /// The loop stopped and the `join` rule chose the value the node produces.
1044    Converged(Value),
1045    /// A pass parked the run (an `agent` body's budget crossing or a `tool`
1046    /// body's suspension), propagated up as a graph park at the fold node. No
1047    /// join is recorded for that pass, so a later drive re-drives the SAME pass.
1048    Parked {
1049        /// The fold node that parked.
1050        node: String,
1051        /// Why it parked.
1052        reason: ParkReason,
1053    },
1054}
1055
1056/// Drives one fold node's bounded loop inline and sequentially into the parent
1057/// log.
1058///
1059/// Refuses an unsupported body form **before** recording the fold's
1060/// `NodeEntered`, so nothing lands in the log past such a refusal. Then records
1061/// `NodeEntered` and, for each pass in index order, `FoldIterationStarted`, the
1062/// body's work inline, and `FoldIterationJoined`. Pass 0's input is the fold's
1063/// routed value; every later pass's input is the previous pass's output, which
1064/// IS the accumulated value (the document has no vocabulary for a separate
1065/// accumulator or a merge rule). Both go through the same unwrap out of an MCP
1066/// result envelope (see [`unwrap_pass_output`]), so the body sees ONE shape
1067/// across the whole loop whether the fold was fed over an edge or by itself.
1068/// The loop stops
1069/// when `stop_when` holds over the
1070/// pass just joined, or when `max_iterations` passes have run. A bound reached
1071/// by a fold declaring `on_bound: fail` is a typed
1072/// [`EngineError::FoldBoundExceeded`] returned in place of the convergence;
1073/// otherwise the `join`
1074/// rule picks the winner, `FoldConverged` records it with the stop reason, and
1075/// `NodeExited` closes the node.
1076async fn drive_fold(
1077    ctx: &mut RunCtx,
1078    plan: &FoldPlan<'_>,
1079    routed: &Value,
1080    by_id: &HashMap<&str, &Node>,
1081    agents: &impl AgentResolver,
1082    tools: &impl ToolResolver,
1083    graph_hash: &str,
1084) -> Result<FoldOutcome, EngineError> {
1085    let fold = plan.node;
1086    let node_id = fold.id.as_str();
1087
1088    // Resolve the body up front so an unsupported body form refuses BEFORE the
1089    // fold's NodeEntered is recorded.
1090    let body: &Node = match &fold.body {
1091        FoldBody::Node(target) => {
1092            let body_node =
1093                by_id
1094                    .get(target.as_str())
1095                    .copied()
1096                    .ok_or_else(|| EngineError::MalformedGraph {
1097                        detail: format!(
1098                            "fold node `{node_id}`: body names unknown node `{target}`"
1099                        ),
1100                    })?;
1101            match body_node {
1102                Node::Agent(_) | Node::Tool(_) => body_node,
1103                other => {
1104                    return Err(EngineError::UnsupportedFoldBody {
1105                        node: node_id.to_owned(),
1106                        detail: format!(
1107                            "a `{}` body node cannot be a per-pass worker; only `agent` and `tool` bodies run",
1108                            other.kind_name()
1109                        ),
1110                    });
1111                }
1112            }
1113        }
1114        FoldBody::Subgraph(_) => {
1115            return Err(EngineError::UnsupportedFoldBody {
1116                node: node_id.to_owned(),
1117                detail: "an embedded `subgraph` body is not executed yet".to_owned(),
1118            });
1119        }
1120    };
1121
1122    // The validator requires a bound of at least 1; the engine re-checks it
1123    // defensively, before NodeEntered, because a zero bound would leave the
1124    // join with no pass to choose.
1125    if fold.max_iterations < 1 {
1126        return Err(EngineError::MalformedGraph {
1127            detail: format!(
1128                "fold node `{node_id}`: max_iterations must be at least 1, found {}",
1129                fold.max_iterations
1130            ),
1131        });
1132    }
1133
1134    ctx.node_entered(node_id).await?;
1135
1136    // Every pass's output, in pass order. The latest entry is the accumulated
1137    // value the next pass folds over and the predicate reads; the whole list is
1138    // what the join rule chooses from. Not pre-sized to the bound: the bound is
1139    // author-supplied and may be enormous, and each pass does real work, so
1140    // growing costs nothing next to reserving for passes that may never run.
1141    let mut passes: Vec<Value> = Vec::new();
1142    // THE ENTRY VALUE. The fold's routed value goes through the SAME unwrap its
1143    // pass outputs do. A fold reached over an inbound edge from a `tool` node is
1144    // handed that node's recorded output, which for a tool reached over MCP is
1145    // the whole result envelope; without this, pass 0 would fold the envelope
1146    // while every later pass folds a bare payload, so one body tool would see two
1147    // shapes in a single run and pass 0 would read every field at a path that is
1148    // not there. Derived once, here, because this is the single point pass 0's
1149    // input is chosen, and a fold that IS the entry node folds its graph input
1150    // exactly as before (a plain object is not an envelope and passes through
1151    // verbatim). Nothing outside a fold changes: an ordinary edge still routes a
1152    // node's recorded output verbatim, which is what branch expressions reading
1153    // `structuredContent.` depend on.
1154    let entry = unwrap_pass_output(routed.clone());
1155    let mut stopped_by_predicate = false;
1156    for position in 0..fold.max_iterations {
1157        let index = u64::from(position);
1158        ctx.fold_iteration_started(node_id, index).await?;
1159        let call = BodyCall {
1160            owner: BodyOwner::Fold,
1161            graph_hash,
1162            node_id,
1163            index,
1164        };
1165        // Pass 0 folds over the unwrapped entry value; every later pass folds
1166        // over the pass before it, which was unwrapped as it was pushed. Bound to
1167        // its own statement so the borrow of `passes` ends before the outcome is
1168        // pushed onto it.
1169        let outcome = {
1170            let input = passes.last().unwrap_or(&entry);
1171            run_body(ctx, body, input, agents, tools, call).await?
1172        };
1173        match outcome {
1174            // THE UNWRAP POINT for a pass output; the entry value above is the
1175            // only other one, and it calls the same function. The accumulated
1176            // value the fold carries is the pass output's `structuredContent`
1177            // payload when the output is an MCP result envelope, and the output
1178            // verbatim otherwise (see `unwrap_pass_output`). Applying it here,
1179            // where the output is pushed onto `passes`, is what makes every
1180            // consumer agree: the next pass's input, the `stop_when` predicate,
1181            // the `best_by` argmax, and the join's own output all read this
1182            // vector and therefore all read the bare payload. Nothing about the
1183            // RECORDING changes: `ToolCallCompleted` already holds the full
1184            // envelope and still does. This is derivation from recorded data,
1185            // pure and total, so a replay re-derives the identical value.
1186            IterationOutcome::Output(output) => passes.push(unwrap_pass_output(output)),
1187            // No join is recorded for a parked pass, so the next drive re-enters
1188            // this same pass and re-runs its body.
1189            IterationOutcome::Parked(reason) => {
1190                return Ok(FoldOutcome::Parked {
1191                    node: node_id.to_owned(),
1192                    reason,
1193                });
1194            }
1195        }
1196        ctx.fold_iteration_joined(node_id, index).await?;
1197        // The predicate reads the pass that just joined. It is a pure function
1198        // of recorded output, so replay re-decides identically.
1199        let joined = passes.last().expect("the pass that just joined");
1200        if plan.stop_when.eval(joined) {
1201            stopped_by_predicate = true;
1202            break;
1203        }
1204    }
1205
1206    // THE BOUND VERDICT, before the join. A fold that declares `on_bound: fail`
1207    // treats `stop_when` as a REQUIREMENT, not an early exit, so reaching the
1208    // bound without it holding means the loop converged on nothing and there is
1209    // no value to join. The join is not consulted at all: a `best_by` argmax
1210    // over passes that all fell short would answer a question this fold did not
1211    // ask, so this check sits ahead of it and wins.
1212    //
1213    // THE RECORDING POINT, chosen deliberately. The passes and their joins are
1214    // already durably in the log and stay there: that work really happened, a
1215    // replay must reproduce it, and an operator reading the run needs to see
1216    // what the loop actually tried. What is refused is the CONVERGENCE. So this
1217    // returns from exactly the line `fold_converged` would have occupied: no
1218    // `FoldConverged`, no `NodeExited`, no terminal from the engine, which is
1219    // the same before-the-record discipline `FoldNoComparableCandidate` already
1220    // follows one arm below. An absent `on_bound`, or `OnBound::Join`, never
1221    // reaches this branch and behaves exactly as it did before the field
1222    // existed.
1223    if !stopped_by_predicate && fold.on_bound == Some(OnBound::Fail) {
1224        return Err(EngineError::FoldBoundExceeded {
1225            node: node_id.to_owned(),
1226            bound: fold.max_iterations,
1227        });
1228    }
1229
1230    // The bound is at least 1 and every pass either pushed its output or
1231    // returned, so there is always a last pass here.
1232    let last = passes.len() - 1;
1233    let last_index = last as u64;
1234    let (winner_index, output) = match &fold.join {
1235        FoldJoin::Last => (last_index, passes[last].clone()),
1236        // Every pass contributes to an `all` join's output, so no single pass is
1237        // the winner; the recorded winner_index reads as the pass the loop
1238        // stopped at, which is what bounds the list.
1239        FoldJoin::All => (last_index, Value::Array(passes.clone())),
1240        FoldJoin::BestBy(reference) => {
1241            let parsed = plan
1242                .best_by
1243                .as_ref()
1244                .expect("a best_by join parses its reference at load");
1245            let index = best_by_index(&passes, parsed).ok_or_else(|| {
1246                EngineError::FoldNoComparableCandidate {
1247                    node: node_id.to_owned(),
1248                    reference: reference.clone(),
1249                }
1250            })?;
1251            (index as u64, passes[index].clone())
1252        }
1253    };
1254
1255    ctx.fold_converged(
1256        node_id,
1257        winner_index,
1258        &stop_reason(fold, stopped_by_predicate, passes.len()),
1259    )
1260    .await?;
1261    ctx.node_exited(node_id).await?;
1262    Ok(FoldOutcome::Converged(output))
1263}
1264
1265/// The value a fold actually folds, given a recorded one: the
1266/// `structuredContent` payload when the value is an MCP result envelope, and the
1267/// value verbatim otherwise. Applied to a pass's output and to the fold's own
1268/// entry value, so a fold folds bare payloads whether they entered over an edge
1269/// or were produced by a pass.
1270///
1271/// A fold pass whose body is a `tool` node produces the RECORDED TOOL RESULT.
1272/// For a tool reached over MCP that result is an envelope,
1273/// `{content, structuredContent?, isError?}`: the human-readable rendering
1274/// beside the machine-readable payload. The payload is the value the loop is
1275/// actually folding, so carrying the envelope forward would make every fold
1276/// expression reach through a transport detail (`structuredContent.score`
1277/// rather than `score`), and would make the same predicate wrong against a
1278/// native tool, which returns its flat struct with no envelope at all. Unwrap
1279/// once, here, and the document says the same thing whichever tool answers.
1280///
1281/// # What counts as an envelope
1282///
1283/// An object with BOTH a `content` array and a `structuredContent` key, which
1284/// is the recorded shape and the whole recorded shape. `salvor-tools` hands the
1285/// runtime `serde_json::to_value(&rmcp::model::CallToolResult)`, and that type
1286/// serializes `content: Vec<ContentBlock>` unconditionally (no
1287/// `skip_serializing_if`, so an empty result still writes `"content": []`)
1288/// while `structuredContent`, `isError`, and `_meta` are each written only when
1289/// present. So every MCP result carries a `content` ARRAY, and only a
1290/// structured one carries the payload key beside it.
1291///
1292/// Keying on the payload key ALONE would be wrong, and that is the point of the
1293/// pair: a bare tool or agent output is arbitrary author-shaped JSON, and an
1294/// object that happens to carry a field called `structuredContent` as data is a
1295/// legitimate accumulated value, not a transport wrapper. Such a value has no
1296/// `content` array beside it and passes through whole.
1297///
1298/// Everything else is returned untouched, which is what makes this safe for
1299/// every other body: an `agent` body with an `output_schema` produces a bare
1300/// object, a native tool produces a flat struct, a graph input is whatever the
1301/// operator submitted, and a non-object value (a string, a number, a list) has
1302/// no keys at all.
1303///
1304/// This is a pure, total function of a recorded value, which is what lets it
1305/// sit outside the recording entirely: `ToolCallCompleted` still holds the full
1306/// envelope, and this is re-derived from it identically on every replay. The
1307/// document validator's fold-reference check reads the same bare payload, so
1308/// what it checks at submit is what the engine folds at run time.
1309fn unwrap_pass_output(output: Value) -> Value {
1310    match output {
1311        // The guard is the envelope test; the `remove` below cannot then miss.
1312        Value::Object(mut fields)
1313            if fields.get("content").is_some_and(Value::is_array)
1314                && fields.contains_key("structuredContent") =>
1315        {
1316            fields
1317                .remove("structuredContent")
1318                .expect("the guard proved the key is present")
1319        }
1320        other => other,
1321    }
1322}
1323
1324/// The index of the pass a `best_by` join wins with: the argmax over every
1325/// pass of the value `reference` names, ordered by the expression language's OWN
1326/// comparison ([`salvor_graph::expr::compare`]), so an argmax and the
1327/// `stop_when` predicate beside it can never order values differently.
1328///
1329/// A pass whose reference is missing, or names a value that does not order
1330/// (anything but a number or a string), cannot win: `compare` answers both
1331/// questions, so no type list is re-derived here. Ties keep the EARLIEST pass,
1332/// because only a strictly greater candidate displaces the incumbent. `None`
1333/// when no pass has a comparable value at all, which the caller refuses with
1334/// before recording a convergence.
1335fn best_by_index(passes: &[Value], reference: &Reference) -> Option<usize> {
1336    let mut best: Option<(usize, &Value)> = None;
1337    for (index, pass) in passes.iter().enumerate() {
1338        let Some(candidate) = reference
1339            .resolve(pass)
1340            .filter(|value| salvor_graph::expr::compare(value, value).is_some())
1341        else {
1342            continue;
1343        };
1344        let wins = match best {
1345            None => true,
1346            Some((_, incumbent)) => {
1347                salvor_graph::expr::compare(candidate, incumbent) == Some(Ordering::Greater)
1348            }
1349        };
1350        if wins {
1351            best = Some((index, candidate));
1352        }
1353    }
1354    best.map(|(index, _)| index)
1355}
1356
1357/// The human-readable reason recorded on `FoldConverged`: which of the two stop
1358/// causes ended the loop, naming the predicate that fired or the bound that was
1359/// reached. A pure function of the document and the pass count, so it reproduces
1360/// byte for byte on replay (the cursor matches the recorded reason). There is no
1361/// third cause: no "failed to improve" rule stops a fold.
1362///
1363/// # Why both read verdict first, expression last
1364///
1365/// This string is rendered after a display prefix that is not this function's to
1366/// change (`fold <node> converged on [<i>]: `), and readers truncate. So the
1367/// VERDICT leads and the author's `stop_when` expression trails: truncation can
1368/// then only ever eat the expression, never the word that says which way the
1369/// loop went. The bound reason in particular has to survive standing beside the
1370/// word "converged", so it says plainly that the join was taken at the bound and
1371/// that the predicate never held, rather than opening with the bound and hiding
1372/// the negation past the cut.
1373///
1374/// The `on_bound: fail` case never reaches here at all: it returns
1375/// [`EngineError::FoldBoundExceeded`] from where `FoldConverged` would have been
1376/// recorded, and that error text is verdict-first already.
1377fn stop_reason(fold: &FoldNode, stopped_by_predicate: bool, passes: usize) -> String {
1378    if stopped_by_predicate {
1379        format!(
1380            "stop_when held after pass {}: `{}`",
1381            passes - 1,
1382            fold.stop_when
1383        )
1384    } else {
1385        format!(
1386            "joined at the max_iterations bound of {}; stop_when never held: `{}`",
1387            fold.max_iterations, fold.stop_when
1388        )
1389    }
1390}
1391
1392/// The derived id of the child run executing one map iteration: `sha256:` over the
1393/// parent run id, the map node id, and the zero-based index.
1394///
1395/// A pure function of recorded data (the parent run id the `RunCtx` carries, the
1396/// document's node id, and the index), so a replay of the parent reconstructs the
1397/// identical id without storing anything extra, and it is stable across processes
1398/// and languages. Reuses `salvor-runtime`'s canonical hashing, the same story
1399/// behind [`graph_hash`] and [`fork_safe_idempotency_key`]. Iterations run
1400/// inline in the parent log today, so no separate log exists under this id
1401/// yet; it is recorded on `MapIterationStarted` as the durable, forward-compatible
1402/// identity a future concurrent-child-run would key each iteration's own log
1403/// on.
1404fn map_child_run_id(parent_run: RunId, node_id: &str, index: u64) -> String {
1405    hash_value(&json!({
1406        "parent_run": parent_run,
1407        "node": node_id,
1408        "index": index,
1409    }))
1410}
1411
1412/// Resolves a map node's `over` reference against the routed value to the list of
1413/// items to fan out over.
1414///
1415/// The reference uses the same path grammar and missing-path semantics the branch
1416/// expressions use (see [`salvor_graph::expr::parse_reference`]). A reference that
1417/// fails to parse is a [`EngineError::MalformedGraph`] that does not arise for a
1418/// validated document; one that resolves to anything but a JSON array, including
1419/// a missing path, is a typed [`EngineError::MapOverNotAList`].
1420fn resolve_over(node_id: &str, over: &str, routed: &Value) -> Result<Vec<Value>, EngineError> {
1421    let reference =
1422        salvor_graph::expr::parse_reference(over).map_err(|error| EngineError::MalformedGraph {
1423            detail: format!(
1424                "map node `{node_id}`: `over` reference `{over}` is unparseable: {error}"
1425            ),
1426        })?;
1427    match reference.resolve(routed) {
1428        Some(Value::Array(items)) => Ok(items.clone()),
1429        _ => Err(EngineError::MapOverNotAList {
1430            node: node_id.to_owned(),
1431            over: over.to_owned(),
1432        }),
1433    }
1434}
1435
1436/// The idempotency key a graph `tool` node's [`Effect::Idempotent`] call
1437/// presents: a pure function of the call's POSITION in the graph (the graph
1438/// hash, the node id, and the call index within the node), never of drawn
1439/// randomness.
1440///
1441/// This is what makes an idempotent tool fork-safe. A fork re-walks the segment
1442/// from its fork node, re-executing the idempotent calls in it live; deriving
1443/// the key from position means each re-executed call presents the IDENTICAL key
1444/// its origin recorded, so the provider collapses the duplicate. Drawing the key
1445/// from [`RunCtx::random`](salvor_runtime::RunCtx::random) instead would mint a
1446/// fresh key in the fork, and the provider would see a second, distinct call.
1447/// With this derivation, [`Effect::Write`] is the only effect class a fork must
1448/// have acknowledged, because a [`Effect::Read`] re-executes freely and a
1449/// [`Effect::Idempotent`] retry collapses.
1450///
1451/// `call_index` is the zero-based position of the call within the node. A `tool`
1452/// node makes exactly one call, so it is always `0` today; the parameter is
1453/// carried so a future node kind issuing several calls keeps their keys distinct.
1454///
1455/// Reuses `salvor-runtime`'s canonical hashing (the same behind `graph_hash`
1456/// itself), so the key is reproducible and stable across processes and
1457/// languages. Existing recorded logs are not disturbed: a key is plain data in
1458/// the log, and replay correlates on the RECORDED request, never on a
1459/// re-derivation, so a log whose idempotent call recorded a random-drawn key
1460/// still replays byte for byte under this build.
1461fn fork_safe_idempotency_key(graph_hash: &str, node_id: &str, call_index: u64) -> String {
1462    hash_value(&serde_json::json!({
1463        "graph_hash": graph_hash,
1464        "node": node_id,
1465        "call": call_index,
1466    }))
1467}
1468
1469/// The reason recorded for every [`salvor_core::Event::NodeSkipped`]: a constant,
1470/// so it is trivially a pure function of the run and reproduces byte for byte on
1471/// replay (the cursor matches the recorded reason).
1472const SKIP_REASON: &str = "no live inbound edge: an upstream branch routed to another case";
1473
1474/// Every branch node's cases, parsed once at load: the branch node id maps to
1475/// its cases as `(case name, optional parsed expression)` pairs, where the
1476/// expression is `None` for a `model_decision` case. All ids and names borrow
1477/// the graph document.
1478type ParsedBranches<'a> = HashMap<&'a str, Vec<(&'a str, Option<Expr>)>>;
1479
1480/// Parses every branch node's case conditions once, up front. An `expression`
1481/// case parses to an [`Expr`]; a `model_decision` case has no expression, so it
1482/// stores `None`. The validator already guarantees each expression parses, so a
1483/// failure here is a [`EngineError::MalformedGraph`] that does not arise for a
1484/// validated document.
1485fn parse_branches(graph: &Graph) -> Result<ParsedBranches<'_>, EngineError> {
1486    let mut parsed = HashMap::new();
1487    for node in &graph.nodes {
1488        let Node::Branch(branch) = node else {
1489            continue;
1490        };
1491        let mut cases = Vec::with_capacity(branch.cases.len());
1492        for case in &branch.cases {
1493            let expr = match &case.when {
1494                BranchCondition::Expression(source) => {
1495                    Some(salvor_graph::expr::parse(source).map_err(|error| {
1496                        EngineError::MalformedGraph {
1497                            detail: format!(
1498                                "branch node `{}`: case `{}` has an unparseable condition: {error}",
1499                                branch.id, case.name
1500                            ),
1501                        }
1502                    })?)
1503                }
1504                BranchCondition::ModelDecision => None,
1505            };
1506            cases.push((case.name.as_str(), expr));
1507        }
1508        parsed.insert(branch.id.as_str(), cases);
1509    }
1510    Ok(parsed)
1511}
1512
1513/// One fold node's parsed plan: the node itself plus its expression fields,
1514/// parsed once at load rather than once per pass.
1515struct FoldPlan<'a> {
1516    /// The document's fold node, borrowed for the drive.
1517    node: &'a FoldNode,
1518    /// The `stop_when` predicate, evaluated against each pass's output.
1519    stop_when: Expr,
1520    /// The `best_by` join's reference, `None` for the `last` and `all` joins,
1521    /// which carry no reference.
1522    best_by: Option<Reference>,
1523}
1524
1525/// Every fold node's plan, keyed by node id (both borrow the graph document).
1526type ParsedFolds<'a> = HashMap<&'a str, FoldPlan<'a>>;
1527
1528/// Parses every fold node's expression fields once, up front, exactly as
1529/// [`parse_branches`] does for the branch conditions: the `stop_when` predicate
1530/// and, for a `best_by` join, its reference. The validator already guarantees
1531/// both parse, so a failure here is a [`EngineError::MalformedGraph`] that does
1532/// not arise for a validated document.
1533fn parse_folds(graph: &Graph) -> Result<ParsedFolds<'_>, EngineError> {
1534    let mut parsed = HashMap::new();
1535    for node in &graph.nodes {
1536        let Node::Fold(fold) = node else {
1537            continue;
1538        };
1539        let stop_when = salvor_graph::expr::parse(&fold.stop_when).map_err(|error| {
1540            EngineError::MalformedGraph {
1541                detail: format!(
1542                    "fold node `{}`: `stop_when` is unparseable: {error}",
1543                    fold.id
1544                ),
1545            }
1546        })?;
1547        let best_by = match &fold.join {
1548            FoldJoin::BestBy(reference) => Some(
1549                salvor_graph::expr::parse_reference(reference).map_err(|error| {
1550                    EngineError::MalformedGraph {
1551                        detail: format!(
1552                            "fold node `{}`: the `best_by` reference `{reference}` is unparseable: {error}",
1553                            fold.id
1554                        ),
1555                    }
1556                })?,
1557            ),
1558            FoldJoin::Last | FoldJoin::All => None,
1559        };
1560        parsed.insert(
1561            fold.id.as_str(),
1562            FoldPlan {
1563                node: fold,
1564                stop_when,
1565                best_by,
1566            },
1567        );
1568    }
1569    Ok(parsed)
1570}
1571
1572/// The input a node receives: the recorded output of its live inbound edge, or
1573/// the graph input for an entry node (no inbound edge). Returns `None` when no
1574/// inbound edge is live, which means the node was routed past and must be
1575/// skipped.
1576///
1577/// An inbound edge is live when its source ran (was not skipped) and, if the
1578/// source is a branch, the edge realizes the case that fired. Among several live
1579/// inbound edges the smallest source id wins, so a merge is a pure function of
1580/// the document.
1581fn select_input(
1582    id: &str,
1583    inbound: &HashMap<&str, Vec<&Edge>>,
1584    by_id: &HashMap<&str, &Node>,
1585    branch_case: &HashMap<&str, String>,
1586    skipped: &HashSet<&str>,
1587    outputs: &HashMap<&str, Value>,
1588    graph_input: &Value,
1589) -> Option<Value> {
1590    let edges = inbound.get(id).map(Vec::as_slice).unwrap_or_default();
1591    if edges.is_empty() {
1592        return Some(graph_input.clone());
1593    }
1594    let mut chosen: Option<&Edge> = None;
1595    for edge in edges {
1596        if !is_live_inbound(edge, by_id, branch_case, skipped) {
1597            continue;
1598        }
1599        chosen = match chosen {
1600            Some(best) if best.from <= edge.from => Some(best),
1601            _ => Some(edge),
1602        };
1603    }
1604    chosen.map(|edge| {
1605        outputs
1606            .get(edge.from.as_str())
1607            .cloned()
1608            .unwrap_or(Value::Null)
1609    })
1610}
1611
1612/// Whether an inbound edge carries a live value into its destination: the source
1613/// ran, and if the source is a branch the edge's label names the fired case.
1614fn is_live_inbound(
1615    edge: &Edge,
1616    by_id: &HashMap<&str, &Node>,
1617    branch_case: &HashMap<&str, String>,
1618    skipped: &HashSet<&str>,
1619) -> bool {
1620    if skipped.contains(edge.from.as_str()) {
1621        return false;
1622    }
1623    match by_id.get(edge.from.as_str()) {
1624        // A branch only lets the edge realizing its fired case through.
1625        Some(Node::Branch(_)) => {
1626            branch_case.get(edge.from.as_str()).map(String::as_str) == edge.label.as_deref()
1627        }
1628        // Every non-branch source feeds all of its outbound edges.
1629        _ => true,
1630    }
1631}
1632
1633/// The human-readable suspension reason a gate parks under: its prompt when it
1634/// has one, else a phrase derived from the node id. A pure function of the
1635/// document, so it reproduces on replay.
1636fn gate_reason(gate: &GateNode) -> String {
1637    gate.prompt
1638        .clone()
1639        .unwrap_or_else(|| format!("approval required at gate `{}`", gate.id))
1640}
1641
1642/// Picks the first expression case whose condition is true, in author order.
1643/// Returns [`EngineError::NoBranchCaseMatched`] when none fires. A
1644/// `model_decision` case reaching here means an expression branch (no
1645/// `agent_hash`) carried one, which the validator rejects, so it is a
1646/// [`EngineError::MalformedGraph`] unreachable for a validated document.
1647fn choose_expression_case<'a>(
1648    node_id: &str,
1649    cases: &'a [(&'a str, Option<Expr>)],
1650    value: &Value,
1651) -> Result<&'a str, EngineError> {
1652    for (name, expr) in cases {
1653        match expr {
1654            Some(expr) if expr.eval(value) => return Ok(name),
1655            Some(_) => {}
1656            None => {
1657                return Err(EngineError::MalformedGraph {
1658                    detail: format!(
1659                        "branch node `{node_id}`: an expression branch must not carry a model-decision case"
1660                    ),
1661                });
1662            }
1663        }
1664    }
1665    Err(EngineError::NoBranchCaseMatched {
1666        node: node_id.to_owned(),
1667    })
1668}
1669
1670/// Maps a decision agent's reply to a case name: the reply's final text,
1671/// trimmed, must exactly equal one of the branch's case names. Anything else is
1672/// [`EngineError::BranchDecisionUnmatched`], listing the case names.
1673fn match_decision<'a>(branch: &'a BranchNode, reply: &Value) -> Result<&'a str, EngineError> {
1674    let reply_text = reply
1675        .as_str()
1676        .map_or_else(|| reply.to_string(), |text| text.trim().to_owned());
1677    for case in &branch.cases {
1678        if case.name == reply_text {
1679            return Ok(case.name.as_str());
1680        }
1681    }
1682    Err(EngineError::BranchDecisionUnmatched {
1683        node: branch.id.clone(),
1684        reply: reply_text,
1685        cases: branch.cases.iter().map(|case| case.name.clone()).collect(),
1686    })
1687}