Expand description
The Salvor graph engine: drives a frozen graph document through its nodes, recording the walk into one durable run log.
§Where this crate sits, and why it is its own crate
The engine is deliberately not part of salvor-runtime (that would drag
the graph document format into the built-in agent loop) and not part of
salvor-graph (that crate is a pure, IO-free leaf). It sits above both and
composes only their public surfaces: the graph document from salvor-graph,
and the durability substrate (RunCtx,
drive_loop) from salvor-runtime. It reaches
into nothing private. That is a deliberate proof of the runtime’s API
guardrail: everything the engine needs, an outside crate could also do.
§What it drives
run_graph opens a run’s log with GraphRunStarted, walks the nodes in
deterministic topological order (see [walk]), and drives each one:
- an agent node runs the built-in agent loop
(
drive_loop) inside the same log, framed byNodeEntered/NodeExited; - a tool node records one tool call through the same write-ahead intent/completion machinery the built-in loop uses, honoring the tool’s effect class;
- a gate node parks the run through the exact
Suspended/Resumedmachinery the built-in loop uses for a tool suspension: entering it recordsNodeEntered, thensuspendrecords the gate’sapproval_schemaas the suspension schema and the drive returnsGraphOutcome::Parked. A later drive over the log (carrying the resume input the existing resume machinery appended) passes that input through the gate as its output and continues. A gate needs no event kind of its own; - a branch node routes on its input: an expression branch evaluates its
cases in author order and the first true case wins; a model-decision branch
drives the node’s agent and maps the reply to a case name. Either way the
chosen case is recorded as
BranchTaken, the walk follows the like-named edge, and every node reachable only through a non-taken case is recordedNodeSkipped; - a map node fans out over a list. Its
overreference resolves against the routed value to a JSON array (a non-array is a typedEngineError::MapOverNotAListrefused beforeNodeEntered); the engine recordsNodeEntered, thenMapFannedOutwith the resolved item list, then walks the list IN INDEX ORDER, and for each element recordsMapIterationStarted(carrying the derived child-run id), runs the body’s work inline, and recordsMapIterationJoined. The joined output is the per-element outputs as a list in index order. Iterations run inline and sequentially in the parent’s own log: theconcurrencycap is accepted (the validator requires it be at least 1) but not honored — a deliberate v0.4 choice that costs only wall-clock and changes no event shape, so the whole fan-out is proven by the same single-log replay machinery already proven for linear and branching graphs. Concurrent child runs are not yet supported. Asubgraphbody, or a body node that is not anagentortool, is a typedEngineError::UnsupportedMapBodyrefused beforeNodeEntered.
A node that is a map’s body is executed ONLY as that map’s per-item worker; it
is never walked independently, so its own events (a tool call, an agent loop)
are recorded inline between the map’s iteration markers and its node id is
never framed with a NodeEntered of its own. That keeps node ids unambiguous
in the one log and is why forking INTO a map iteration is refused: an iteration
is not a node boundary (see plan_fork).
After the last node the engine records the single terminal RunCompleted.
There is no ambient clock or randomness in any decision: everything the
engine feeds forward — the walk order, each node’s input, the branch route, a
map’s resolved item list and its per-iteration child ids, an idempotent tool’s
idempotency key — is a pure function of the document or of values the RunCtx
recorded, so a second drive over the recorded log replays with no live calls
and produces a byte-identical log. A map iteration’s child-run id is
sha256: over the parent run id, the node id, and the index (see
[map_child_run_id]) — pure recorded data, so replay reconstructs the
identical id without storing anything extra. The idempotency
key is derived from the call’s position in the graph (graph hash, node id,
call index) rather than from drawn randomness, which is what lets a FORK of a
run re-walk a segment and present the same key its origin recorded — see
[fork_safe_idempotency_key] and the salvor-server fork endpoint.
§Data flow
Each node’s output flows to its successors along the edges, and a node’s
input is the recorded output of the live inbound edge that reaches it (the
graph input for an entry node with no inbound edge). A branch passes its
routed value through unchanged to the taken case’s edge; the decision only
selects the route, never the data. A tool node’s input references are still
not resolved yet; the upstream output is the downstream input
verbatim. When more than one live inbound edge reaches a node, the one whose
source id is smallest wins, so the merge is a pure function of the document.
§Resolving agents and tools
A node names its agent by hash and its tool by name; the engine turns those
into executables through the AgentResolver and ToolResolver traits
the caller supplies. Tests inject maps; the server wires its own
registries in separately. Keeping resolution behind a trait is what lets the engine stay
ignorant of where agents and tools actually come from.
Re-exports§
pub use fork::ForkError;pub use fork::ForkPlan;pub use fork::WriteHazard;pub use fork::plan_fork;
Modules§
- fork
- Fork planning: the pure computation behind fork-from-node.
Enums§
- Engine
Error - Why a graph drive could not continue.
- Graph
Outcome - How a graph drive ended.
Traits§
- Agent
Resolver - Resolves an
agentnode’s declared hash to theAgentthat executes it. - Tool
Resolver - Resolves a
toolnode’s declared name to theDynToolthat executes it.
Functions§
- graph_
hash - Computes a graph document’s content hash:
sha256:over its canonical JSON, the exact string recorded inGraphRunStarted. - run_
graph - Drives
graphto completion (or a park) overctx, recording the walk into the run’s log.