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`;
23//! - a **tool** node records one tool call through the same write-ahead
24//!   intent/completion machinery the built-in loop uses, honoring the tool's
25//!   effect class;
26//! - a **gate** node parks the run through the exact `Suspended` / `Resumed`
27//!   machinery the built-in loop uses for a tool suspension: entering it records
28//!   `NodeEntered`, then `suspend` records the gate's `approval_schema` as the
29//!   suspension schema and the drive returns [`GraphOutcome::Parked`]. A later
30//!   drive over the log (carrying the resume input the existing resume machinery
31//!   appended) passes that input through the gate as its output and continues.
32//!   A gate needs no event kind of its own. A resume input is ENFORCED against
33//!   the gate's `approval_schema` at the accept edge, between the `suspend` and
34//!   the `await_resume` that would record it, so a non-conforming approval is a
35//!   typed refusal that appends nothing and leaves the run parked; a recorded
36//!   `Resumed` is never re-judged on replay (see [`approval`]);
37//! - a **branch** node routes on its input: an expression branch evaluates its
38//!   cases in author order and the first true case wins; a model-decision branch
39//!   drives the node's agent and maps the reply to a case name. Either way the
40//!   chosen case is recorded as `BranchTaken`, the walk follows the like-named
41//!   edge, and every node reachable only through a non-taken case is recorded
42//!   `NodeSkipped`;
43//! - a **map** node fans out over a list. Its `over` reference resolves against
44//!   the routed value to a JSON array (a non-array is a typed
45//!   [`EngineError::MapOverNotAList`] refused before `NodeEntered`); the engine
46//!   records `NodeEntered`, then `MapFannedOut` with the resolved item list, then
47//!   walks the list IN INDEX ORDER, and for each element records
48//!   `MapIterationStarted` (carrying the derived child-run id), runs the body's
49//!   work inline, and records `MapIterationJoined`. The joined output is the
50//!   per-element outputs as a list in index order. Iterations run
51//!   **inline and sequentially** in the parent's own log: the `concurrency` cap is
52//!   accepted (the validator requires it be at least 1) but not honored: a
53//!   deliberate v0.4 choice that costs only wall-clock and changes no event shape,
54//!   so the whole fan-out is proven by the same single-log replay machinery
55//!   already proven for linear and branching graphs. Concurrent child runs are
56//!   not yet supported. A
57//!   `subgraph` body, or a body node that is not an `agent` or `tool`, is a typed
58//!   [`EngineError::UnsupportedMapBody`] refused before `NodeEntered`.
59//!
60//! A node that is a map's body is executed ONLY as that map's per-item worker; it
61//! is never walked independently, so its own events (a tool call, an agent loop)
62//! are recorded inline between the map's iteration markers and its node id is
63//! never framed with a `NodeEntered` of its own. That keeps node ids unambiguous
64//! in the one log and is why forking INTO a map iteration is refused: an iteration
65//! is not a node boundary (see [`plan_fork`]).
66//!
67//! After the last node the engine records the single terminal `RunCompleted`.
68//! There is no ambient clock or randomness in any decision: everything the
69//! engine feeds forward (the walk order, each node's input, the branch route, a
70//! map's resolved item list and its per-iteration child ids, an idempotent tool's
71//! idempotency key) is a pure function of the document or of values the `RunCtx`
72//! recorded, so a second drive over the recorded log replays with no live calls
73//! and produces a byte-identical log. A map iteration's child-run id is
74//! `sha256:` over the parent run id, the node id, and the index (see
75//! [`map_child_run_id`]): pure recorded data, so replay reconstructs the
76//! identical id without storing anything extra. The idempotency
77//! key is derived from the call's position in the graph (graph hash, node id,
78//! call index) rather than from drawn randomness, which is what lets a FORK of a
79//! run re-walk a segment and present the same key its origin recorded. See
80//! [`fork_safe_idempotency_key`] and the `salvor-server` fork endpoint.
81//!
82//! # Data flow
83//!
84//! Each node's output flows to its successors along the edges, and a node's
85//! input is the recorded output of the live inbound edge that reaches it (the
86//! graph input for an entry node with no inbound edge). A branch passes its
87//! routed value through unchanged to the taken case's edge; the decision only
88//! selects the route, never the data. A tool node's `input` references are still
89//! not resolved yet; the upstream output is the downstream input
90//! verbatim. When more than one live inbound edge reaches a node, the one whose
91//! source id is smallest wins, so the merge is a pure function of the document.
92//!
93//! # Resolving agents and tools
94//!
95//! A node names its agent by hash and its tool by name; the engine turns those
96//! into executables through the [`AgentResolver`] and [`ToolResolver`] traits
97//! the caller supplies. Tests inject maps; the server wires its own
98//! registries in separately. Keeping resolution behind a trait is what lets the engine stay
99//! ignorant of where agents and tools actually come from.
100
101#![warn(missing_docs)]
102
103pub mod approval;
104mod error;
105pub mod fork;
106mod walk;
107
108use std::collections::{HashMap, HashSet};
109
110use salvor_core::{Effect, RunId};
111use salvor_graph::expr::Expr;
112use salvor_graph::{BranchCondition, BranchNode, Edge, GateNode, Graph, MapBody, MapNode, Node};
113use salvor_runtime::{
114    Agent, LoopOutcome, ParkReason, Resumption, RunCtx, ToolCallResult, drive_loop, hash_value,
115};
116use salvor_tools::DynTool;
117use serde_json::{Value, json};
118
119pub use approval::{ApprovalViolation, approval_violations, parked_gate};
120pub use error::EngineError;
121pub use fork::{ForkError, ForkPlan, WriteHazard, plan_fork};
122
123/// Resolves an `agent` node's declared hash to the [`Agent`] that executes it.
124///
125/// A small trait, not a fixed type, so a test can inject a map while the server
126/// injects its agent registry. A [`HashMap<String, Agent>`](std::collections::HashMap)
127/// implements it out of the box.
128pub trait AgentResolver {
129    /// The agent registered under `agent_hash`, or `None` if none is.
130    fn resolve_agent(&self, agent_hash: &str) -> Option<&Agent>;
131}
132
133/// Resolves a `tool` node's declared name to the [`DynTool`] that executes it.
134///
135/// The tool counterpart of [`AgentResolver`]. A
136/// [`HashMap<String, Box<dyn DynTool>>`](std::collections::HashMap) implements
137/// it out of the box.
138pub trait ToolResolver {
139    /// The tool registered under `name`, or `None` if none is.
140    fn resolve_tool(&self, name: &str) -> Option<&dyn DynTool>;
141}
142
143impl AgentResolver for HashMap<String, Agent> {
144    fn resolve_agent(&self, agent_hash: &str) -> Option<&Agent> {
145        self.get(agent_hash)
146    }
147}
148
149impl ToolResolver for HashMap<String, Box<dyn DynTool>> {
150    fn resolve_tool(&self, name: &str) -> Option<&dyn DynTool> {
151        self.get(name).map(AsRef::as_ref)
152    }
153}
154
155/// How a graph drive ended.
156#[derive(Debug)]
157pub enum GraphOutcome {
158    /// The graph ran to completion; this is the final output the terminal
159    /// `RunCompleted` recorded.
160    Completed {
161        /// The graph run's final output (the last node's output).
162        output: Value,
163    },
164    /// A node parked the run durably (an agent's budget crossing or a tool
165    /// suspension). The run survives restarts; resume it through the runtime's
166    /// resume path, then drive the graph again to continue.
167    Parked {
168        /// The node that parked.
169        node: String,
170        /// Why it parked.
171        reason: ParkReason,
172    },
173}
174
175/// Computes a graph document's content hash: `sha256:` over its canonical JSON,
176/// the exact string recorded in `GraphRunStarted`.
177///
178/// Reuses `salvor-runtime`'s canonical hashing (the same story behind
179/// `agent_def_hash` and `request_hash`), so a graph run's `graph_hash` is
180/// reproducible and matches whatever a control plane computes for the same
181/// document.
182///
183/// # Errors
184///
185/// [`EngineError::GraphEncode`] if the document cannot be serialized (it always
186/// can; the edge is kept honest rather than panicking).
187pub fn graph_hash(graph: &Graph) -> Result<String, EngineError> {
188    let value = serde_json::to_value(graph).map_err(EngineError::GraphEncode)?;
189    Ok(hash_value(&value))
190}
191
192/// Drives `graph` to completion (or a park) over `ctx`, recording the walk into
193/// the run's log.
194///
195/// The log opens with `GraphRunStarted { graph_hash }`, each node contributes
196/// `NodeEntered` … its own events … `NodeExited`, and the run closes with one
197/// `RunCompleted`. See the crate docs for the node handling and determinism
198/// guarantees. Fresh, recovering, or replaying is entirely the `ctx`'s
199/// business: the engine issues the same sequence of `RunCtx` calls either way,
200/// which is what makes a second drive over the recorded log a byte-identical,
201/// zero-live-call replay.
202///
203/// # Errors
204///
205/// [`EngineError::MapOverNotAList`] when a map node's `over` reference does not
206/// resolve to a list, and [`EngineError::UnsupportedMapBody`] for a `subgraph` or
207/// non-`agent`/`tool` body (both before the map's `NodeEntered` is recorded);
208/// [`EngineError::NoBranchCaseMatched`] when an expression branch
209/// matches no case (also before its `NodeEntered`);
210/// [`EngineError::BranchDecisionUnmatched`] when a model-decision branch's agent
211/// names no case (after its `NodeEntered`, since the model had to run);
212/// [`EngineError::UnknownAgent`] / [`EngineError::UnknownTool`] when a resolver
213/// cannot supply a node's executable; [`EngineError::MalformedGraph`] when the
214/// topology is not a DAG (or, unreachable in practice, a branch condition the
215/// validator accepted fails to parse here); [`EngineError::ToolFailed`] when a
216/// tool call fails; [`EngineError::Runtime`] for any replay divergence,
217/// reconciliation refusal, provider, or store error.
218pub async fn run_graph(
219    ctx: &mut RunCtx,
220    graph: &Graph,
221    input: &Value,
222    agents: &impl AgentResolver,
223    tools: &impl ToolResolver,
224) -> Result<GraphOutcome, EngineError> {
225    let hash = graph_hash(graph)?;
226    // The recorded input always wins on replay; `begin_graph` returns it.
227    let graph_input = ctx.begin_graph(&hash, input).await?;
228
229    // Topology and routing state, all keyed on ids that borrow the document.
230    let by_id: HashMap<&str, &Node> = graph.nodes.iter().map(|n| (n.id(), n)).collect();
231    let mut inbound: HashMap<&str, Vec<&Edge>> = HashMap::new();
232    for edge in &graph.edges {
233        inbound.entry(edge.to.as_str()).or_default().push(edge);
234    }
235    // Branch conditions are parsed ONCE here (the validator already guarantees
236    // they parse; a failure now is a MalformedGraph unreachable in practice).
237    let branches = parse_branches(graph)?;
238    // The ids of every node used as a `map` body: they are the per-item workers
239    // of their map and are executed ONLY inside its fan-out, never walked
240    // independently, so their events stay unambiguous inside the one log.
241    let map_body_targets: HashSet<&str> = graph
242        .nodes
243        .iter()
244        .filter_map(|node| match node {
245            Node::Map(map) => match &map.body {
246                MapBody::Node(target) => Some(target.as_str()),
247                MapBody::Subgraph(_) => None,
248            },
249            _ => None,
250        })
251        .collect();
252
253    // What each executed node produced, which nodes were skipped, and which case
254    // each branch fired: the pure state the routing reads. `last_output` threads
255    // the terminal output, seeded with the graph input so an empty graph still
256    // completes with it (matching a linear graph with no nodes at all).
257    let mut outputs: HashMap<&str, Value> = HashMap::new();
258    let mut skipped: HashSet<&str> = HashSet::new();
259    let mut branch_case: HashMap<&str, String> = HashMap::new();
260    let mut last_output = graph_input.clone();
261
262    for node in walk::walk_order(graph)? {
263        let id = node.id();
264        // A node used as a map body is not walked independently: it runs only as
265        // its map's per-item worker, inline in the fan-out below. Nothing is
266        // recorded for it here: it is map-owned, not "skipped".
267        if map_body_targets.contains(id) {
268            continue;
269        }
270        // A node with no live inbound edge was routed past: record the skip (its
271        // sole marker) and move on. Predecessors are visited first in topological
272        // order, so their skip/branch state is already known here.
273        let Some(node_input) = select_input(
274            id,
275            &inbound,
276            &by_id,
277            &branch_case,
278            &skipped,
279            &outputs,
280            &graph_input,
281        ) else {
282            ctx.node_skipped(id, SKIP_REASON).await?;
283            skipped.insert(id);
284            continue;
285        };
286
287        match node {
288            Node::Agent(agent_node) => {
289                let agent = agents
290                    .resolve_agent(&agent_node.agent_hash)
291                    .ok_or_else(|| EngineError::UnknownAgent {
292                        node: agent_node.id.clone(),
293                        agent_hash: agent_node.agent_hash.clone(),
294                    })?;
295                ctx.node_entered(id).await?;
296                // The agent loop runs inside this same log via the runtime's
297                // begin/drive_loop split: no second run head, and it returns the
298                // output without recording a terminal (the engine owns that).
299                match drive_loop(ctx, agent, &node_input).await? {
300                    LoopOutcome::Completed(output) => {
301                        ctx.node_exited(id).await?;
302                        last_output = output.clone();
303                        outputs.insert(id, output);
304                    }
305                    LoopOutcome::Parked(reason) => {
306                        return Ok(GraphOutcome::Parked {
307                            node: agent_node.id.clone(),
308                            reason,
309                        });
310                    }
311                }
312            }
313            Node::Tool(tool_node) => {
314                let tool = tools.resolve_tool(&tool_node.tool).ok_or_else(|| {
315                    EngineError::UnknownTool {
316                        node: tool_node.id.clone(),
317                        tool: tool_node.tool.clone(),
318                    }
319                })?;
320                ctx.node_entered(id).await?;
321                // An idempotent tool's key is a PURE function of WHERE the call
322                // sits in the graph (the graph hash, the node id, the call index
323                // within the node), not of drawn randomness. That is what makes it
324                // fork-safe: a fork re-walks the segment from its fork node and
325                // re-executes the idempotent calls in it live, and this derivation
326                // hands each of them the IDENTICAL key its origin recorded, so the
327                // provider collapses the duplicate. It also leaves `Effect::Write`
328                // as the sole class a fork must have acknowledged. Read and write
329                // tools carry no key (see the built-in loop).
330                let idempotency_key = match tool.effect() {
331                    Effect::Idempotent => Some(fork_safe_idempotency_key(&hash, id, 0)),
332                    Effect::Read | Effect::Write => None,
333                };
334                match ctx
335                    .tool_call(tool, &node_input, idempotency_key.as_deref())
336                    .await?
337                {
338                    ToolCallResult::Output(output) => {
339                        ctx.node_exited(id).await?;
340                        last_output = output.clone();
341                        outputs.insert(id, output);
342                    }
343                    ToolCallResult::Failed(failure) => {
344                        return Err(EngineError::ToolFailed {
345                            node: tool_node.id.clone(),
346                            message: failure.message,
347                        });
348                    }
349                    ToolCallResult::Suspended(suspension) => {
350                        ctx.suspend(&suspension.reason, &suspension.input_schema)
351                            .await?;
352                        match ctx.await_resume().await? {
353                            Resumption::Parked => {
354                                return Ok(GraphOutcome::Parked {
355                                    node: tool_node.id.clone(),
356                                    reason: ParkReason::Suspended {
357                                        reason: suspension.reason,
358                                        input_schema: suspension.input_schema,
359                                    },
360                                });
361                            }
362                            Resumption::Resumed(resume_input) => {
363                                // The recorded resume input is the tool's answer.
364                                ctx.node_exited(id).await?;
365                                last_output = resume_input.clone();
366                                outputs.insert(id, resume_input);
367                            }
368                        }
369                    }
370                }
371            }
372            // A gate parks through the exact suspension machinery a tool uses:
373            // NodeEntered, then `suspend` recording the gate's approval schema,
374            // then a park. A later drive over the resumed log passes the resume
375            // input through as the gate's output. No gate-specific event kind.
376            Node::Gate(gate) => {
377                ctx.node_entered(id).await?;
378                let reason = gate_reason(gate);
379                ctx.suspend(&reason, &gate.approval_schema).await?;
380                // THE ACCEPT EDGE. Right here, and nowhere later, is where a
381                // resume input may be judged: the gate's `Suspended` is on
382                // disk, and the next line can append a `Resumed`. Refusing
383                // before that append is what keeps the refusal free: nothing
384                // lands in the log and the run stays parked at this gate,
385                // waiting for an approval that conforms.
386                //
387                // The guard is `is_replaying()`. When history remains, the next
388                // event is a RECORDED `Resumed`, and a recorded `Resumed` is
389                // never re-judged: replay trusts what was written. That is
390                // load-bearing rather than an optimization. If replay
391                // re-validated, a stricter validator (or a new `jsonschema`
392                // release) would turn logs that replayed yesterday into
393                // refusals today, and a durable log that stops replaying is not
394                // durable. So: check what has not been written, trust what has.
395                if !ctx.is_replaying()
396                    && let Some(input) = ctx.staged_resume_input()
397                {
398                    let violations = approval_violations(input, &gate.approval_schema);
399                    if !violations.is_empty() {
400                        return Err(EngineError::ApprovalSchemaViolation {
401                            node: gate.id.clone(),
402                            violations,
403                        });
404                    }
405                }
406                match ctx.await_resume().await? {
407                    Resumption::Parked => {
408                        return Ok(GraphOutcome::Parked {
409                            node: gate.id.clone(),
410                            reason: ParkReason::Suspended {
411                                reason,
412                                input_schema: gate.approval_schema.clone(),
413                            },
414                        });
415                    }
416                    Resumption::Resumed(resume_input) => {
417                        ctx.node_exited(id).await?;
418                        last_output = resume_input.clone();
419                        outputs.insert(id, resume_input);
420                    }
421                }
422            }
423            Node::Branch(branch) => {
424                // A branch is a pure router: whichever case fires, the routed
425                // value passes through unchanged to the taken edge.
426                let cases = branches.get(id).expect("every branch node is parsed");
427                let chosen: String = match &branch.agent_hash {
428                    // Expression branch: choose purely, so a no-match refuses
429                    // before NodeEntered and nothing lands past the refusal.
430                    None => {
431                        let case = choose_expression_case(id, cases, &node_input)?;
432                        ctx.node_entered(id).await?;
433                        case.to_owned()
434                    }
435                    // Model-decision branch: the agent must run first, so its
436                    // NodeEntered and model events precede the mapping (and the
437                    // BranchDecisionUnmatched refusal, if the reply names no case).
438                    Some(agent_hash) => {
439                        let agent = agents.resolve_agent(agent_hash).ok_or_else(|| {
440                            EngineError::UnknownAgent {
441                                node: branch.id.clone(),
442                                agent_hash: agent_hash.clone(),
443                            }
444                        })?;
445                        ctx.node_entered(id).await?;
446                        let reply = match drive_loop(ctx, agent, &node_input).await? {
447                            LoopOutcome::Completed(output) => output,
448                            LoopOutcome::Parked(reason) => {
449                                return Ok(GraphOutcome::Parked {
450                                    node: branch.id.clone(),
451                                    reason,
452                                });
453                            }
454                        };
455                        match_decision(branch, &reply)?.to_owned()
456                    }
457                };
458                ctx.branch_taken(id, &chosen).await?;
459                ctx.node_exited(id).await?;
460                branch_case.insert(id, chosen);
461                last_output = node_input.clone();
462                outputs.insert(id, node_input);
463            }
464            Node::Map(map_node) => {
465                match drive_map(ctx, map_node, &node_input, &by_id, agents, tools, &hash).await? {
466                    MapOutcome::Joined(output) => {
467                        last_output = output.clone();
468                        outputs.insert(id, output);
469                    }
470                    MapOutcome::Parked { node, reason } => {
471                        return Ok(GraphOutcome::Parked { node, reason });
472                    }
473                }
474            }
475            // A fold's execution semantics are not implemented yet, so the
476            // engine refuses it with a typed error BEFORE recording its
477            // `NodeEntered`. The fold still validates as a legal
478            // document, and its markers and projection exist for client-driven
479            // runs to record against; the engine simply does not drive the loop.
480            Node::Fold(fold) => {
481                return Err(EngineError::UnsupportedNode {
482                    node: fold.id.clone(),
483                    kind: "fold",
484                });
485            }
486        }
487    }
488
489    ctx.complete_run(&last_output).await?;
490    Ok(GraphOutcome::Completed {
491        output: last_output,
492    })
493}
494
495/// How driving one map node's fan-out ended.
496enum MapOutcome {
497    /// Every iteration joined; the map's output is the per-index outputs as a
498    /// JSON array in index order.
499    Joined(Value),
500    /// An iteration parked the run (an `agent` body's budget crossing or a `tool`
501    /// body's suspension), propagated up as a graph park at the map node.
502    Parked {
503        /// The map node that parked.
504        node: String,
505        /// Why it parked.
506        reason: ParkReason,
507    },
508}
509
510/// Drives one map node's fan-out inline and sequentially into the parent log.
511///
512/// Refuses an unsupported body form or a non-list `over` **before** recording the
513/// map's `NodeEntered`, so nothing lands in the log past such a refusal. Then
514/// records `NodeEntered`, `MapFannedOut` with the resolved item list, and for each
515/// element in INDEX ORDER records `MapIterationStarted` (with the derived
516/// child-run id), runs the body's work inline, and records `MapIterationJoined`.
517/// The `concurrency` cap is accepted but not honored: iterations run
518/// one after another, which is why the whole fan-out is a plain single-log replay.
519async fn drive_map(
520    ctx: &mut RunCtx,
521    map_node: &MapNode,
522    routed: &Value,
523    by_id: &HashMap<&str, &Node>,
524    agents: &impl AgentResolver,
525    tools: &impl ToolResolver,
526    graph_hash: &str,
527) -> Result<MapOutcome, EngineError> {
528    let node_id = map_node.id.as_str();
529
530    // Resolve the body up front so an unsupported body form refuses BEFORE the
531    // map's NodeEntered is recorded.
532    let body: &Node = match &map_node.body {
533        MapBody::Node(target) => {
534            let body_node =
535                by_id
536                    .get(target.as_str())
537                    .copied()
538                    .ok_or_else(|| EngineError::MalformedGraph {
539                        detail: format!("map node `{node_id}`: body names unknown node `{target}`"),
540                    })?;
541            match body_node {
542                Node::Agent(_) | Node::Tool(_) => body_node,
543                other => {
544                    return Err(EngineError::UnsupportedMapBody {
545                        node: node_id.to_owned(),
546                        detail: format!(
547                            "a `{}` body node cannot be a per-item worker; only `agent` and `tool` bodies run",
548                            other.kind_name()
549                        ),
550                    });
551                }
552            }
553        }
554        MapBody::Subgraph(_) => {
555            return Err(EngineError::UnsupportedMapBody {
556                node: node_id.to_owned(),
557                detail: "an embedded `subgraph` body is not executed yet".to_owned(),
558            });
559        }
560    };
561
562    // Resolve `over` against the routed value; a non-array (including a missing
563    // path) is a typed refusal BEFORE NodeEntered.
564    let items = resolve_over(node_id, &map_node.over, routed)?;
565
566    ctx.node_entered(node_id).await?;
567    ctx.map_fanned_out(node_id, &Value::Array(items.clone()))
568        .await?;
569
570    let mut joined: Vec<Value> = Vec::with_capacity(items.len());
571    for (position, item) in items.iter().enumerate() {
572        let index = position as u64;
573        let child_run = map_child_run_id(ctx.run_id(), node_id, index);
574        ctx.map_iteration_started(node_id, index, &child_run)
575            .await?;
576        let call = MapCall {
577            graph_hash,
578            node_id,
579            index,
580        };
581        match run_map_body(ctx, body, item, agents, tools, call).await? {
582            IterationOutcome::Output(output) => joined.push(output),
583            IterationOutcome::Parked(reason) => {
584                return Ok(MapOutcome::Parked {
585                    node: node_id.to_owned(),
586                    reason,
587                });
588            }
589        }
590        // Joins are recorded strictly in index order, never completion order.
591        ctx.map_iteration_joined(node_id, index).await?;
592    }
593    ctx.node_exited(node_id).await?;
594    Ok(MapOutcome::Joined(Value::Array(joined)))
595}
596
597/// How one map iteration's body work ended.
598enum IterationOutcome {
599    /// The body produced this output for the iteration.
600    Output(Value),
601    /// The body parked (an agent budget crossing or a tool suspension).
602    Parked(ParkReason),
603}
604
605/// Where one map iteration's tool call sits, for deriving its fork-safe
606/// idempotency key: the graph hash, the map node id, and the iteration index.
607struct MapCall<'a> {
608    /// The graph document hash.
609    graph_hash: &'a str,
610    /// The map node id (the "node" this iteration is a call of).
611    node_id: &'a str,
612    /// The zero-based iteration index (the call index within the map node).
613    index: u64,
614}
615
616/// Runs one map iteration's body work inline: the referenced `agent` or `tool`
617/// node's work with `item` as its input, recorded in the parent log WITHOUT a
618/// `NodeEntered` frame of its own (the iteration markers bracket it instead). The
619/// body kind was already validated as `agent` or `tool` by [`drive_map`].
620async fn run_map_body(
621    ctx: &mut RunCtx,
622    body: &Node,
623    item: &Value,
624    agents: &impl AgentResolver,
625    tools: &impl ToolResolver,
626    call: MapCall<'_>,
627) -> Result<IterationOutcome, EngineError> {
628    match body {
629        Node::Agent(agent_node) => {
630            let agent = agents
631                .resolve_agent(&agent_node.agent_hash)
632                .ok_or_else(|| EngineError::UnknownAgent {
633                    node: agent_node.id.clone(),
634                    agent_hash: agent_node.agent_hash.clone(),
635                })?;
636            match drive_loop(ctx, agent, item).await? {
637                LoopOutcome::Completed(output) => Ok(IterationOutcome::Output(output)),
638                LoopOutcome::Parked(reason) => Ok(IterationOutcome::Parked(reason)),
639            }
640        }
641        Node::Tool(tool_node) => {
642            let tool =
643                tools
644                    .resolve_tool(&tool_node.tool)
645                    .ok_or_else(|| EngineError::UnknownTool {
646                        node: tool_node.id.clone(),
647                        tool: tool_node.tool.clone(),
648                    })?;
649            // Each iteration is a distinct call of the MAP node, so its idempotent
650            // key is derived from the map node id and the index: the "several
651            // calls within one node" case `fork_safe_idempotency_key`'s call-index
652            // parameter exists for. A fork re-walking the fan-out presents each
653            // iteration's identical key; Read/Write carry none.
654            let idempotency_key = match tool.effect() {
655                Effect::Idempotent => Some(fork_safe_idempotency_key(
656                    call.graph_hash,
657                    call.node_id,
658                    call.index,
659                )),
660                Effect::Read | Effect::Write => None,
661            };
662            match ctx
663                .tool_call(tool, item, idempotency_key.as_deref())
664                .await?
665            {
666                ToolCallResult::Output(output) => Ok(IterationOutcome::Output(output)),
667                ToolCallResult::Failed(failure) => Err(EngineError::ToolFailed {
668                    node: tool_node.id.clone(),
669                    message: failure.message,
670                }),
671                ToolCallResult::Suspended(suspension) => {
672                    ctx.suspend(&suspension.reason, &suspension.input_schema)
673                        .await?;
674                    match ctx.await_resume().await? {
675                        Resumption::Parked => Ok(IterationOutcome::Parked(ParkReason::Suspended {
676                            reason: suspension.reason,
677                            input_schema: suspension.input_schema,
678                        })),
679                        Resumption::Resumed(resume_input) => {
680                            Ok(IterationOutcome::Output(resume_input))
681                        }
682                    }
683                }
684            }
685        }
686        // `drive_map` validated the body kind as agent or tool before calling here.
687        other => Err(EngineError::UnsupportedMapBody {
688            node: call.node_id.to_owned(),
689            detail: format!(
690                "a `{}` body node cannot be a per-item worker",
691                other.kind_name()
692            ),
693        }),
694    }
695}
696
697/// The derived id of the child run executing one map iteration: `sha256:` over the
698/// parent run id, the map node id, and the zero-based index.
699///
700/// A pure function of recorded data (the parent run id the `RunCtx` carries, the
701/// document's node id, and the index), so a replay of the parent reconstructs the
702/// identical id without storing anything extra, and it is stable across processes
703/// and languages. Reuses `salvor-runtime`'s canonical hashing, the same story
704/// behind [`graph_hash`] and [`fork_safe_idempotency_key`]. Iterations run
705/// inline in the parent log today, so no separate log exists under this id
706/// yet; it is recorded on `MapIterationStarted` as the durable, forward-compatible
707/// identity a future concurrent-child-run would key each iteration's own log
708/// on.
709fn map_child_run_id(parent_run: RunId, node_id: &str, index: u64) -> String {
710    hash_value(&json!({
711        "parent_run": parent_run,
712        "node": node_id,
713        "index": index,
714    }))
715}
716
717/// Resolves a map node's `over` reference against the routed value to the list of
718/// items to fan out over.
719///
720/// The reference uses the same path grammar and missing-path semantics the branch
721/// expressions use (see [`salvor_graph::expr::parse_reference`]). A reference that
722/// fails to parse is a [`EngineError::MalformedGraph`] that does not arise for a
723/// validated document; one that resolves to anything but a JSON array, including
724/// a missing path, is a typed [`EngineError::MapOverNotAList`].
725fn resolve_over(node_id: &str, over: &str, routed: &Value) -> Result<Vec<Value>, EngineError> {
726    let reference =
727        salvor_graph::expr::parse_reference(over).map_err(|error| EngineError::MalformedGraph {
728            detail: format!(
729                "map node `{node_id}`: `over` reference `{over}` is unparseable: {error}"
730            ),
731        })?;
732    match reference.resolve(routed) {
733        Some(Value::Array(items)) => Ok(items.clone()),
734        _ => Err(EngineError::MapOverNotAList {
735            node: node_id.to_owned(),
736            over: over.to_owned(),
737        }),
738    }
739}
740
741/// The idempotency key a graph `tool` node's [`Effect::Idempotent`] call
742/// presents: a pure function of the call's POSITION in the graph (the graph
743/// hash, the node id, and the call index within the node), never of drawn
744/// randomness.
745///
746/// This is what makes an idempotent tool fork-safe. A fork re-walks the segment
747/// from its fork node, re-executing the idempotent calls in it live; deriving
748/// the key from position means each re-executed call presents the IDENTICAL key
749/// its origin recorded, so the provider collapses the duplicate. Drawing the key
750/// from [`RunCtx::random`](salvor_runtime::RunCtx::random) instead would mint a
751/// fresh key in the fork, and the provider would see a second, distinct call.
752/// With this derivation, [`Effect::Write`] is the only effect class a fork must
753/// have acknowledged, because a [`Effect::Read`] re-executes freely and a
754/// [`Effect::Idempotent`] retry collapses.
755///
756/// `call_index` is the zero-based position of the call within the node. A `tool`
757/// node makes exactly one call, so it is always `0` today; the parameter is
758/// carried so a future node kind issuing several calls keeps their keys distinct.
759///
760/// Reuses `salvor-runtime`'s canonical hashing (the same behind `graph_hash`
761/// itself), so the key is reproducible and stable across processes and
762/// languages. Existing recorded logs are not disturbed: a key is plain data in
763/// the log, and replay correlates on the RECORDED request, never on a
764/// re-derivation, so a log whose idempotent call recorded a random-drawn key
765/// still replays byte for byte under this build.
766fn fork_safe_idempotency_key(graph_hash: &str, node_id: &str, call_index: u64) -> String {
767    hash_value(&serde_json::json!({
768        "graph_hash": graph_hash,
769        "node": node_id,
770        "call": call_index,
771    }))
772}
773
774/// The reason recorded for every [`salvor_core::Event::NodeSkipped`]: a constant,
775/// so it is trivially a pure function of the run and reproduces byte for byte on
776/// replay (the cursor matches the recorded reason).
777const SKIP_REASON: &str = "no live inbound edge: an upstream branch routed to another case";
778
779/// Every branch node's cases, parsed once at load: the branch node id maps to
780/// its cases as `(case name, optional parsed expression)` pairs, where the
781/// expression is `None` for a `model_decision` case. All ids and names borrow
782/// the graph document.
783type ParsedBranches<'a> = HashMap<&'a str, Vec<(&'a str, Option<Expr>)>>;
784
785/// Parses every branch node's case conditions once, up front. An `expression`
786/// case parses to an [`Expr`]; a `model_decision` case has no expression, so it
787/// stores `None`. The validator already guarantees each expression parses, so a
788/// failure here is a [`EngineError::MalformedGraph`] that does not arise for a
789/// validated document.
790fn parse_branches(graph: &Graph) -> Result<ParsedBranches<'_>, EngineError> {
791    let mut parsed = HashMap::new();
792    for node in &graph.nodes {
793        let Node::Branch(branch) = node else {
794            continue;
795        };
796        let mut cases = Vec::with_capacity(branch.cases.len());
797        for case in &branch.cases {
798            let expr = match &case.when {
799                BranchCondition::Expression(source) => {
800                    Some(salvor_graph::expr::parse(source).map_err(|error| {
801                        EngineError::MalformedGraph {
802                            detail: format!(
803                                "branch node `{}`: case `{}` has an unparseable condition: {error}",
804                                branch.id, case.name
805                            ),
806                        }
807                    })?)
808                }
809                BranchCondition::ModelDecision => None,
810            };
811            cases.push((case.name.as_str(), expr));
812        }
813        parsed.insert(branch.id.as_str(), cases);
814    }
815    Ok(parsed)
816}
817
818/// The input a node receives: the recorded output of its live inbound edge, or
819/// the graph input for an entry node (no inbound edge). Returns `None` when no
820/// inbound edge is live, which means the node was routed past and must be
821/// skipped.
822///
823/// An inbound edge is live when its source ran (was not skipped) and, if the
824/// source is a branch, the edge realizes the case that fired. Among several live
825/// inbound edges the smallest source id wins, so a merge is a pure function of
826/// the document.
827fn select_input(
828    id: &str,
829    inbound: &HashMap<&str, Vec<&Edge>>,
830    by_id: &HashMap<&str, &Node>,
831    branch_case: &HashMap<&str, String>,
832    skipped: &HashSet<&str>,
833    outputs: &HashMap<&str, Value>,
834    graph_input: &Value,
835) -> Option<Value> {
836    let edges = inbound.get(id).map(Vec::as_slice).unwrap_or_default();
837    if edges.is_empty() {
838        return Some(graph_input.clone());
839    }
840    let mut chosen: Option<&Edge> = None;
841    for edge in edges {
842        if !is_live_inbound(edge, by_id, branch_case, skipped) {
843            continue;
844        }
845        chosen = match chosen {
846            Some(best) if best.from <= edge.from => Some(best),
847            _ => Some(edge),
848        };
849    }
850    chosen.map(|edge| {
851        outputs
852            .get(edge.from.as_str())
853            .cloned()
854            .unwrap_or(Value::Null)
855    })
856}
857
858/// Whether an inbound edge carries a live value into its destination: the source
859/// ran, and if the source is a branch the edge's label names the fired case.
860fn is_live_inbound(
861    edge: &Edge,
862    by_id: &HashMap<&str, &Node>,
863    branch_case: &HashMap<&str, String>,
864    skipped: &HashSet<&str>,
865) -> bool {
866    if skipped.contains(edge.from.as_str()) {
867        return false;
868    }
869    match by_id.get(edge.from.as_str()) {
870        // A branch only lets the edge realizing its fired case through.
871        Some(Node::Branch(_)) => {
872            branch_case.get(edge.from.as_str()).map(String::as_str) == edge.label.as_deref()
873        }
874        // Every non-branch source feeds all of its outbound edges.
875        _ => true,
876    }
877}
878
879/// The human-readable suspension reason a gate parks under: its prompt when it
880/// has one, else a phrase derived from the node id. A pure function of the
881/// document, so it reproduces on replay.
882fn gate_reason(gate: &GateNode) -> String {
883    gate.prompt
884        .clone()
885        .unwrap_or_else(|| format!("approval required at gate `{}`", gate.id))
886}
887
888/// Picks the first expression case whose condition is true, in author order.
889/// Returns [`EngineError::NoBranchCaseMatched`] when none fires. A
890/// `model_decision` case reaching here means an expression branch (no
891/// `agent_hash`) carried one, which the validator rejects, so it is a
892/// [`EngineError::MalformedGraph`] unreachable for a validated document.
893fn choose_expression_case<'a>(
894    node_id: &str,
895    cases: &'a [(&'a str, Option<Expr>)],
896    value: &Value,
897) -> Result<&'a str, EngineError> {
898    for (name, expr) in cases {
899        match expr {
900            Some(expr) if expr.eval(value) => return Ok(name),
901            Some(_) => {}
902            None => {
903                return Err(EngineError::MalformedGraph {
904                    detail: format!(
905                        "branch node `{node_id}`: an expression branch must not carry a model-decision case"
906                    ),
907                });
908            }
909        }
910    }
911    Err(EngineError::NoBranchCaseMatched {
912        node: node_id.to_owned(),
913    })
914}
915
916/// Maps a decision agent's reply to a case name: the reply's final text,
917/// trimmed, must exactly equal one of the branch's case names. Anything else is
918/// [`EngineError::BranchDecisionUnmatched`], listing the case names.
919fn match_decision<'a>(branch: &'a BranchNode, reply: &Value) -> Result<&'a str, EngineError> {
920    let reply_text = reply
921        .as_str()
922        .map_or_else(|| reply.to_string(), |text| text.trim().to_owned());
923    for case in &branch.cases {
924        if case.name == reply_text {
925            return Ok(case.name.as_str());
926        }
927    }
928    Err(EngineError::BranchDecisionUnmatched {
929        node: branch.id.clone(),
930        reply: reply_text,
931        cases: branch.cases.iter().map(|case| case.name.clone()).collect(),
932    })
933}