From fcb152e4080d0a28a61ca7d117a4044718f0a0c6 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 9 Mar 2026 11:27:46 -0700 Subject: [PATCH] feat(wasm): lazy schema injection on WASM tool errors (#638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/tools/wasm/error.rs | 35 +++++++++++++++++++++++++-- src/tools/wasm/wrapper.rs | 51 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/tools/wasm/error.rs b/src/tools/wasm/error.rs index 1a910fa3..8bbb8202 100644 --- a/src/tools/wasm/error.rs +++ b/src/tools/wasm/error.rs @@ -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")); + } } diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs index a09c1c4f..0bdf8bfa 100644 --- a/src/tools/wasm/wrapper.rs +++ b/src/tools/wasm/wrapper.rs @@ -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) -> 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 {