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}