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