use thiserror::Error as ThisError;
const ENVELOPE_EXCERPT: usize = 200;
#[derive(Debug, ThisError)]
#[non_exhaustive]
pub enum Error {
#[error(
"the Needle engine is already in use by another instance in this process\n\
The engine is one process-global model with no handles, so only one Needle can exist \
at a time.\n\
Help: drop the existing Needle before building another, or share it behind a mutex."
)]
EngineBusy,
#[error(
"different weights are already loaded in this process\n\
The engine copies the archive on the first load and offers no way to unload it.\n\
Help: build every Needle from the same weights, or run the second model in its own \
process."
)]
WeightsAlreadyLoaded,
#[error(
"unsupported weights: magic tag {tag:#010X} is not a Needle 3 archive\n\
Needle 3 archives start with the little-endian tag 0x05E12A84; 0x05E12A83 is a \
Needle 2 archive, which the linked engine cannot read.\n\
Help: download needle3.cact from the Cactus-Compute/needle3 repository, or call \
Weights::fetch()."
)]
UnsupportedWeights {
tag: u32,
},
#[error(
"the engine rejected the weight archive\n\
The magic tag was right but the engine could not load the bytes, which usually means a \
truncated or corrupted download.\n\
Help: delete the cached archive and fetch it again, or verify its SHA-256."
)]
Load,
#[error(
"the engine could not initialise the conversation prefix\n\
needle_init returned a non-positive token count, which happens when the weights are not \
loaded or the tool index path does not exist.\n\
Help: check that the tool index path exists and that the weights loaded successfully."
)]
Init,
#[error(
"the engine failed to complete the request: {detail}\n\
Help: reset the conversation, shorten the input, or raise max_new_tokens."
)]
Complete {
detail: String,
},
#[error(
"the engine truncated its response to fill the {capacity} byte output buffer\n\
needle_complete truncates without reporting it, so the JSON envelope is incomplete.\n\
Help: lower max_new_tokens, or ask for fewer tool calls in one turn."
)]
Truncated {
capacity: usize,
},
#[error(
"the engine failed to embed the input\n\
needle_embed returned a negative value, which happens when no weights are loaded.\n\
Help: build the Needle from valid weights before embedding."
)]
Embed,
#[error(
"the {field} contains an interior NUL byte\n\
The engine takes NUL-terminated C strings, so a NUL inside the text would silently cut \
it short.\n\
Help: strip '\\0' from the {field} before passing it in."
)]
InteriorNul {
field: &'static str,
},
#[error(
"the engine returned an envelope this crate could not parse: {source}\n\
Response: {text}\n\
Help: this is a bug or an engine newer than the pinned one; please report the response \
above."
)]
Envelope {
text: String,
#[source]
source: serde_json::Error,
},
#[error(
"the tool call arguments do not fit the requested type: {source}\n\
Help: the model fills arguments from the JSON schema you declared; relax the type, or \
tighten the schema so the engine cannot produce this shape."
)]
Arguments {
#[source]
source: serde_json::Error,
},
#[error(
"the tool declarations could not be serialised: {source}\n\
Help: check the `parameters` value of each Tool; it must be plain JSON."
)]
Tools {
#[source]
source: serde_json::Error,
},
#[error(
"filesystem error: {0}\n\
Help: check that the path exists and that this process may read and write it."
)]
Io(#[from] std::io::Error),
#[error(
"could not download weights from {url}: {detail}\n\
Help: retry, or download the archive by hand and point CACTUS_NEEDLE_WEIGHTS at it."
)]
Download {
url: String,
detail: String,
},
#[error(
"the downloaded weights do not match their pinned checksum\n\
Expected sha256 {expected}, got {actual}.\n\
Help: the download was corrupted or the remote file changed; retry, and report the \
mismatch if it persists."
)]
ChecksumMismatch {
expected: String,
actual: String,
},
}
impl Error {
#[must_use]
pub fn envelope(text: &str, source: serde_json::Error) -> Self {
let excerpt = match text.char_indices().nth(ENVELOPE_EXCERPT) {
Some((cut, _)) => format!("{}... (truncated)", &text[..cut]),
None => text.to_owned(),
};
Error::Envelope {
text: excerpt,
source,
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_is_send_sync_static() {
fn assert_bounds<T: Send + Sync + 'static>() {}
assert_bounds::<Error>();
}
#[test]
fn every_message_ends_with_a_help_line() {
let messages = [
Error::EngineBusy.to_string(),
Error::WeightsAlreadyLoaded.to_string(),
Error::UnsupportedWeights { tag: 0 }.to_string(),
Error::Load.to_string(),
Error::Init.to_string(),
Error::Complete {
detail: "x".to_owned(),
}
.to_string(),
Error::Truncated { capacity: 1 }.to_string(),
Error::Embed.to_string(),
Error::InteriorNul { field: "input" }.to_string(),
Error::Download {
url: "u".to_owned(),
detail: "d".to_owned(),
}
.to_string(),
Error::ChecksumMismatch {
expected: "a".to_owned(),
actual: "b".to_owned(),
}
.to_string(),
];
for message in messages {
let last = message.lines().last().unwrap_or_default();
assert!(last.starts_with("Help: "), "no help line in: {message}");
}
}
#[test]
fn envelope_excerpt_is_bounded() {
let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
let err = Error::envelope(&"a".repeat(4096), source);
let Error::Envelope { text, .. } = &err else {
panic!("expected an envelope error");
};
assert!(text.len() < 4096);
}
#[test]
fn short_envelopes_are_kept_whole() {
let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
let err = Error::envelope("{\"type\":\"nope\"", source);
let Error::Envelope { text, .. } = &err else {
panic!("expected an envelope error");
};
assert_eq!(text, "{\"type\":\"nope\"");
}
}