use salvor_core::{ReplayError, RunId};
use salvor_store::StoreError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum RuntimeError {
#[error("replay: {0}")]
Replay(ReplayError),
#[error("store: {0}")]
Store(StoreError),
#[error("model call: {0}")]
Model(salvor_llm::Error),
#[error("model request did not serialize: {0}")]
RequestEncode(serde_json::Error),
#[error("recorded model response did not decode: {0}")]
RecordedResponseDecode(serde_json::Error),
#[error("run {run_id:?} already has recorded history; use recover or resume")]
RunAlreadyStarted {
run_id: RunId,
},
#[error("run {run_id:?} has no recorded history")]
UnknownRun {
run_id: RunId,
},
#[error("run {run_id:?} is not parked (status: {status}); resume needs a parked run")]
NotParked {
run_id: RunId,
status: String,
},
#[error("resume input rejected: {0}")]
ResumeInputRejected(String),
#[error("invalid labels: {0}")]
InvalidLabels(String),
#[error(
"run {run_id:?} does not need reconciliation (status: {status}); resolve records the completion of a dangling write intent, and this run has none"
)]
NotReconcilable {
run_id: RunId,
status: String,
},
#[error(
"tool `{tool}` under idempotency key `{idempotency_key}` is held by run {holder:?} at seq {holder_seq}, which has not recorded a completion; nothing was executed and nothing was recorded. Finish or reconcile that run before running this one again"
)]
CallInFlight {
tool: String,
idempotency_key: String,
holder: RunId,
holder_seq: u64,
},
#[error(
"tool `{tool}` was called under idempotency key `{idempotency_key}` with an input that differs from the call run {origin:?} already committed at seq {origin_seq}; the key names two different calls, so neither deduplicating nor executing is safe"
)]
IdempotencyKeyCollision {
tool: String,
idempotency_key: String,
origin: RunId,
origin_seq: u64,
},
#[error(
"run {origin:?} was committed to tool `{tool}` under idempotency key `{idempotency_key}` at seq {origin_seq}, but its log holds no such completion; the store disagrees with itself and nothing was executed"
)]
CommitmentUnreadable {
tool: String,
idempotency_key: String,
origin: RunId,
origin_seq: u64,
},
#[error(
"run {run_id:?} is already terminal (status: {status}); there is nothing left to abandon"
)]
AlreadyTerminal {
run_id: RunId,
status: String,
},
}
impl From<ReplayError> for RuntimeError {
fn from(error: ReplayError) -> Self {
RuntimeError::Replay(error)
}
}
impl From<StoreError> for RuntimeError {
fn from(error: StoreError) -> Self {
RuntimeError::Store(error)
}
}
impl From<salvor_llm::Error> for RuntimeError {
fn from(error: salvor_llm::Error) -> Self {
RuntimeError::Model(error)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chain(error: &dyn std::error::Error) -> String {
let mut message = error.to_string();
let mut source = error.source();
while let Some(inner) = source {
message.push_str(": ");
message.push_str(&inner.to_string());
source = inner.source();
}
message
}
#[test]
fn model_error_prints_once_through_the_source_chain_and_plain_display() {
let inner = salvor_llm::Error::Api(salvor_llm::ApiError {
status: 500,
kind: "demo_script_no_conversation".to_owned(),
message: "no conversation name matched the system prompt".to_owned(),
request_id: None,
retry_after: None,
});
let error: RuntimeError = inner.into();
let needle = "no conversation name matched the system prompt";
let chained = chain(&error);
assert_eq!(chained.matches(needle).count(), 1, "{chained}");
assert_eq!(error.to_string().matches(needle).count(), 1, "{error}");
assert!(std::error::Error::source(&error).is_none());
}
}