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}