Skip to main content

salvor_engine/
error.rs

1//! [`EngineError`]: everything that can stop a graph drive.
2//!
3//! Two families sit here. The first is the engine's own refusals, each naming
4//! the offending node: a `map` node whose `over` reference does not resolve to a
5//! list ([`EngineError::MapOverNotAList`]) or whose body form is not
6//! executable ([`EngineError::UnsupportedMapBody`]), an agent or tool the resolver
7//! could not supply, a graph whose topology is not a well-formed DAG, a branch
8//! that no case matched or whose model decision named no case, a tool that
9//! failed, or a node whose kind is not executable
10//! ([`EngineError::UnsupportedNode`], today a `fold`). Most are returned
11//! **before** recording anything for the node they
12//! name, so the log never carries events past the refusal; the two branch-decision
13//! errors that require running a model first are the documented exception (their
14//! `NodeEntered` and the model's events are already recorded when the mapping
15//! fails). The second family is [`EngineError::Runtime`], the plain pass-through
16//! of a [`RuntimeError`] from the `RunCtx` operations the engine drives.
17
18use salvor_runtime::RuntimeError;
19use thiserror::Error;
20
21/// Why a graph drive could not continue.
22#[derive(Debug, Error)]
23pub enum EngineError {
24    /// A `map` node's `over` reference did not resolve to a JSON array against the
25    /// routed value (it was missing, or resolved to a non-array value). A map can
26    /// only fan out over a list, so the engine refuses deterministically rather
27    /// than guessing. Returned **before** the map's `NodeEntered` is recorded, so
28    /// nothing lands in the log past the refusal, and it reproduces on replay: the
29    /// same recorded routed value re-resolves to the same non-list.
30    #[error("map node `{node}`: the `over` reference `{over}` did not resolve to a list")]
31    MapOverNotAList {
32        /// The id of the map node.
33        node: String,
34        /// The `over` reference that failed to resolve to a list.
35        over: String,
36    },
37
38    /// A `map` node's body is a form that is not executable: an embedded
39    /// `subgraph` (per-item sub-walks need their own
40    /// log per iteration to keep node ids unambiguous, which is not implemented
41    /// yet), or a `node` body that
42    /// names a node whose kind cannot be a per-item worker (only `agent` and
43    /// `tool` bodies run). Returned **before** the map's `NodeEntered` is recorded,
44    /// so nothing lands in the log past the refusal. The document layer still
45    /// validates these as legal graphs; only the engine declines to run them.
46    #[error("map node `{node}`: {detail}")]
47    UnsupportedMapBody {
48        /// The id of the map node.
49        node: String,
50        /// What about the body is not supported.
51        detail: String,
52    },
53
54    /// An expression `branch` reached with no case whose condition evaluated
55    /// true. The author declared the cases exhaustively or the graph cannot
56    /// proceed; the engine refuses deterministically rather than guessing a
57    /// route. Returned before the branch's `NodeEntered` is recorded, so nothing
58    /// lands in the log past the refusal, and the refusal reproduces on replay
59    /// (the same routed value re-evaluates to the same no-match).
60    #[error("branch node `{node}`: no case condition matched the routed value")]
61    NoBranchCaseMatched {
62        /// The id of the branch node.
63        node: String,
64    },
65
66    /// A model-decision `branch`'s agent produced a reply that is not one of the
67    /// branch's case names. Unlike the other refusals this arrives **after** the
68    /// branch's `NodeEntered` and the decision agent's own events are recorded
69    /// (the model had to run to produce the reply); it still reproduces on
70    /// replay, because the reply is decoded from the recorded model completion.
71    #[error(
72        "branch node `{node}`: the decision agent replied `{reply}`, which is not one of the cases [{}]",
73        .cases.join(", ")
74    )]
75    BranchDecisionUnmatched {
76        /// The id of the branch node.
77        node: String,
78        /// The agent's reply, trimmed, that named no case.
79        reply: String,
80        /// The branch's case names, in author order.
81        cases: Vec<String>,
82    },
83
84    /// A node whose kind the engine does not execute was reached on the
85    /// walk. Today the sole such kind is `fold`: its execution semantics are
86    /// not implemented, so the engine refuses it with this typed error rather
87    /// than guessing a loop. Returned **before** the node's `NodeEntered`
88    /// is recorded, so nothing lands in the log past the refusal, and it
89    /// reproduces on replay (the same document re-walks to the same refusal). The
90    /// document layer still validates a fold as a legal graph; only the engine
91    /// declines to run it.
92    #[error("node `{node}`: the engine does not execute `{kind}` nodes yet")]
93    UnsupportedNode {
94        /// The id of the node whose kind is not executable here.
95        node: String,
96        /// The node's kind name (`"fold"`).
97        kind: &'static str,
98    },
99
100    /// An `agent` node referenced an agent hash the resolver could not supply.
101    #[error("agent node `{node}`: no agent registered for hash `{agent_hash}`")]
102    UnknownAgent {
103        /// The id of the agent node.
104        node: String,
105        /// The unresolved agent definition hash.
106        agent_hash: String,
107    },
108
109    /// A `tool` node named a tool the resolver could not supply.
110    #[error("tool node `{node}`: no tool registered under the name `{tool}`")]
111    UnknownTool {
112        /// The id of the tool node.
113        node: String,
114        /// The unresolved tool name.
115        tool: String,
116    },
117
118    /// The graph's edges do not form a well-formed DAG (a cycle, or an edge
119    /// referencing a node that is not in the document). The document validator
120    /// rejects both at submit; the engine re-checks defensively so a walk is
121    /// never attempted over a malformed topology.
122    #[error("the graph is not a well-formed acyclic document: {detail}")]
123    MalformedGraph {
124        /// What was wrong with the topology.
125        detail: String,
126    },
127
128    /// A `tool` node's call failed after exhausting its retry policy. The full
129    /// failure is already recorded in the log's `ToolCallCompleted`; this
130    /// carries the message so the caller sees why the graph stopped.
131    #[error("tool node `{node}` failed: {message}")]
132    ToolFailed {
133        /// The id of the tool node that failed.
134        node: String,
135        /// The recorded failure message.
136        message: String,
137    },
138
139    /// The graph document could not be serialized to compute its hash. A graph
140    /// is plain data, so this does not arise in practice; it exists to keep the
141    /// hashing edge honest rather than panicking on a `serde_json` error.
142    #[error("could not serialize the graph document to hash it: {0}")]
143    GraphEncode(#[source] serde_json::Error),
144
145    /// A `RunCtx` operation surfaced a runtime error (replay divergence, a
146    /// dangling write needing reconciliation, a live provider failure, a store
147    /// failure). Passed through unchanged.
148    #[error(transparent)]
149    Runtime(#[from] RuntimeError),
150}