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]>
This commit is contained in:
Henry Park
2026-03-09 11:27:46 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e86b372fa6
commit fcb152e408
2 changed files with 82 additions and 4 deletions
+33 -2
View File
@@ -68,8 +68,15 @@ pub enum WasmError {
Timeout(std::time::Duration),
/// Component returned an error response.
#[error("Tool error: {0}")]
ToolReturnedError(String),
/// 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}")]
@@ -195,4 +202,28 @@ mod tests {
_ => 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"));
}
}
+49 -2
View File
@@ -633,9 +633,13 @@ impl WasmToolWrapper {
// Get logs from host state
let logs = store.data_mut().host_state.take_logs();
// Check for tool-level error
// Check for tool-level error — on failure, call the WASM module's
// description() and schema() exports so the LLM can retry with the
// correct parameters without us having to include the (large) schema
// in every request's tools array.
if let Some(err) = response.error {
return Err(WasmError::ToolReturnedError(err));
let hint = build_tool_hint(tool_iface, &mut store);
return Err(WasmError::ToolReturnedError { message: err, hint });
}
// Return result (or empty string if none)
@@ -643,6 +647,49 @@ impl WasmToolWrapper {
}
}
/// Maximum characters for the description portion of a tool hint.
const HINT_DESC_MAX: usize = 500;
/// Maximum characters for the schema portion of a tool hint.
const HINT_SCHEMA_MAX: usize = 3000;
/// Call the WASM module's `description()` and `schema()` exports to build a
/// hint string. Returns an empty string if both calls fail or return empty.
/// Description is capped at [`HINT_DESC_MAX`] chars, schema at
/// [`HINT_SCHEMA_MAX`] chars.
fn build_tool_hint(tool_iface: &wit_tool::Guest, store: &mut Store<StoreData>) -> String {
let desc = tool_iface
.call_description(&mut *store)
.ok()
.unwrap_or_default();
let schema = tool_iface.call_schema(&mut *store).ok().unwrap_or_default();
if desc.is_empty() && schema.is_empty() {
return String::new();
}
let mut hint = String::new();
if !desc.is_empty() {
hint.push_str("Description: ");
if desc.len() > HINT_DESC_MAX {
let end = crate::util::floor_char_boundary(&desc, HINT_DESC_MAX);
hint.push_str(&desc[..end]);
hint.push('…');
} else {
hint.push_str(&desc);
}
hint.push('\n');
}
if !schema.is_empty() {
hint.push_str("Parameters schema: ");
if schema.len() > HINT_SCHEMA_MAX {
let end = crate::util::floor_char_boundary(&schema, HINT_SCHEMA_MAX);
hint.push_str(&schema[..end]);
hint.push('…');
} else {
hint.push_str(&schema);
}
}
hint
}
#[async_trait]
impl Tool for WasmToolWrapper {
fn name(&self) -> &str {