Files
optimclaw/src/tools/wasm/error.rs
T
fcb152e408 feat(wasm): lazy schema injection on WASM tool errors (#638)
* feat(wasm): lazy schema injection on WASM tool errors

When a WASM tool returns an error (ToolReturnedError), call the module's
description() and schema() WIT exports and append them as a hint in the
error message. This lets the LLM retry with correct parameters without
us including large schemas in every request's tools array.

- Change ToolReturnedError from tuple to struct variant with hint field
- Add build_tool_hint() that calls WASM description()/schema() exports
- Cap description at 500 chars, schema at 3000 chars to limit context
- Hint flows automatically through Display → ToolError → ChatMessage

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: use floor_char_boundary for UTF-8 safe truncation in tool hints

Use existing crate::util::floor_char_boundary() to avoid panicking
when truncation lands mid-multibyte character. Addresses review
feedback on PR #638.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-09 11:27:46 -07:00

230 lines
7.0 KiB
Rust

//! WASM sandbox error types.
use std::fmt;
use thiserror::Error;
/// Errors that can occur during WASM tool execution.
#[derive(Debug, Error)]
pub enum WasmError {
/// Failed to create the Wasmtime engine.
#[error("Engine creation failed: {0}")]
EngineCreationFailed(String),
/// Failed to compile WASM bytes into a component.
#[error("Compilation failed: {0}")]
CompilationFailed(String),
/// WASM validation failed (malformed or invalid component).
#[error("Validation failed: {0}")]
ValidationFailed(String),
/// Failed to instantiate the component.
#[error("Instantiation failed: {0}")]
InstantiationFailed(String),
/// Component execution trapped (e.g., unreachable, memory access violation).
#[error("Execution trapped: {0}")]
Trapped(String),
/// Component panicked during execution.
#[error("Execution panicked: {0}")]
ExecutionPanicked(String),
/// Fuel limit exhausted during execution.
#[error("Fuel exhausted: execution exceeded {limit} fuel units")]
FuelExhausted {
/// The fuel limit that was exceeded.
limit: u64,
},
/// Memory limit exceeded during execution.
#[error("Memory limit exceeded: {used} bytes used, {limit} bytes allowed")]
MemoryExceeded {
/// Bytes used when limit was hit.
used: u64,
/// Maximum allowed bytes.
limit: u64,
},
/// Required export not found in component.
#[error("Missing export: {0}")]
MissingExport(String),
/// IO error (e.g., reading WASM file).
#[error("IO error: {0}")]
IoError(String),
/// Configuration error.
#[error("Configuration error: {0}")]
ConfigError(String),
/// Host function error.
#[error("Host error: {0}")]
HostError(String),
/// Execution timed out.
#[error("Execution timed out after {0:?}")]
Timeout(std::time::Duration),
/// Component returned an error response.
/// When `hint` is non-empty it carries the tool's description and parameter
/// schema so the LLM can retry with correct arguments.
#[error("Tool error: {message}{}", if hint.is_empty() { String::new() } else { format!("\n\nTool usage hint:\n{hint}") })]
ToolReturnedError {
/// The error message from the WASM tool.
message: String,
/// Optional description + schema hint (empty when unavailable).
hint: String,
},
/// Invalid JSON in tool response.
#[error("Invalid response JSON: {0}")]
InvalidResponseJson(String),
/// Path traversal attempt blocked.
#[error("Path traversal blocked: {0}")]
PathTraversalBlocked(String),
}
impl From<std::io::Error> for WasmError {
fn from(e: std::io::Error) -> Self {
WasmError::IoError(e.to_string())
}
}
impl From<WasmError> for crate::tools::ToolError {
fn from(e: WasmError) -> Self {
crate::tools::ToolError::Sandbox(e.to_string())
}
}
/// Details about a trap that occurred during execution.
#[derive(Debug, Clone)]
pub struct TrapInfo {
/// Human-readable trap message.
pub message: String,
/// Trap code if available.
pub code: Option<TrapCode>,
}
impl fmt::Display for TrapInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.code {
Some(code) => write!(f, "{}: {}", code, self.message),
None => write!(f, "{}", self.message),
}
}
}
/// Known trap codes from Wasmtime.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrapCode {
/// Out of bounds memory access.
MemoryOutOfBounds,
/// Out of bounds table access.
TableOutOfBounds,
/// Indirect call type mismatch.
IndirectCallToNull,
/// Signature mismatch on indirect call.
BadSignature,
/// Integer overflow.
IntegerOverflow,
/// Integer division by zero.
IntegerDivisionByZero,
/// Invalid conversion to integer.
BadConversionToInteger,
/// Unreachable instruction executed.
UnreachableCodeReached,
/// Call stack exhausted.
StackOverflow,
/// Out of fuel.
OutOfFuel,
/// Unknown trap code.
Unknown,
}
impl fmt::Display for TrapCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
TrapCode::MemoryOutOfBounds => "memory out of bounds",
TrapCode::TableOutOfBounds => "table out of bounds",
TrapCode::IndirectCallToNull => "indirect call to null",
TrapCode::BadSignature => "bad signature",
TrapCode::IntegerOverflow => "integer overflow",
TrapCode::IntegerDivisionByZero => "integer division by zero",
TrapCode::BadConversionToInteger => "bad conversion to integer",
TrapCode::UnreachableCodeReached => "unreachable code reached",
TrapCode::StackOverflow => "stack overflow",
TrapCode::OutOfFuel => "out of fuel",
TrapCode::Unknown => "unknown trap",
};
write!(f, "{}", s)
}
}
#[cfg(test)]
mod tests {
use crate::tools::wasm::error::{TrapCode, TrapInfo, WasmError};
#[test]
fn test_error_display() {
let err = WasmError::FuelExhausted { limit: 1_000_000 };
assert!(err.to_string().contains("1000000"));
let err = WasmError::MemoryExceeded {
used: 20_000_000,
limit: 10_000_000,
};
assert!(err.to_string().contains("20000000"));
assert!(err.to_string().contains("10000000"));
}
#[test]
fn test_trap_info_display() {
let info = TrapInfo {
message: "access at offset 0x1000".to_string(),
code: Some(TrapCode::MemoryOutOfBounds),
};
let s = info.to_string();
assert!(s.contains("memory out of bounds"));
assert!(s.contains("access at offset"));
}
#[test]
fn test_conversion_to_tool_error() {
let wasm_err = WasmError::Trapped("test trap".to_string());
let tool_err: crate::tools::ToolError = wasm_err.into();
match tool_err {
crate::tools::ToolError::Sandbox(msg) => {
assert!(msg.contains("test trap"));
}
_ => panic!("Expected Sandbox variant"),
}
}
#[test]
fn test_tool_returned_error_without_hint() {
let err = WasmError::ToolReturnedError {
message: "unknown action: foobar".to_string(),
hint: String::new(),
};
let display = err.to_string();
assert!(display.contains("unknown action: foobar"));
assert!(!display.contains("Tool usage hint"));
}
#[test]
fn test_tool_returned_error_with_hint() {
let err = WasmError::ToolReturnedError {
message: "unknown action: foobar".to_string(),
hint: "Description: Gmail tool\nParameters schema: {\"type\":\"object\"}".to_string(),
};
let display = err.to_string();
assert!(display.contains("unknown action: foobar"));
assert!(display.contains("Tool usage hint"));
assert!(display.contains("Gmail tool"));
assert!(display.contains("Parameters schema"));
}
}