Skip to content

feat(wasm): lazy schema injection on WASM tool errors - #638

Merged
henrypark133 merged 2 commits into
mainfrom
wasm-lazy-schema-hint
Mar 9, 2026
Merged

henrypark133 merged 2 commits into
mainfrom
wasm-lazy-schema-hint

Conversation

@henrypark133

Copy link
Copy Markdown
Collaborator

Summary

  • When a WASM tool returns an error, call its description() and schema() WIT exports and inject them as a hint in the error response
  • The LLM sees the hint and can retry with correct parameters, without us including large schemas in every request's tools array
  • Description capped at 500 chars, schema at 3000 chars to prevent context bloat

Problem

WASM tools define description() and schema() exports in their WIT interface, but extract_tool_description() in runtime.rs is a stub that always returns "WASM sandboxed tool" with an empty schema. The LLM has to guess parameter names and gets it wrong, wasting tokens on retries.

Including full schemas in every request would be expensive (some Google API schemas are huge). This PR takes the lazy injection approach: only inject when the tool returns an error.

Changes

  • src/tools/wasm/error.rs — ToolReturnedError changed from (String) to { message, hint } struct variant. Display appends hint when non-empty.
  • src/tools/wasm/wrapper.rs — build_tool_hint() calls the WASM module's description()/schema() exports on the already-instantiated module when execute() returns an error.

Test plan

  • cargo fmt — clean
  • cargo clippy --all --all-features — zero warnings
  • test_tool_returned_error_without_hint — verifies hint is omitted when empty
  • test_tool_returned_error_with_hint — verifies hint appears in Display output
  • Manual: trigger a WASM tool error (e.g., call gmail with bad params) and verify the LLM receives the hint

[skip-regression-check]

Generated with Claude Code

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) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 6, 2026 23:04
@github-actions github-actions Bot added scope: tool/wasm WASM tool sandbox size: M 50-199 changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Mar 6, 2026
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly improves the interaction between LLMs and WASM tools by implementing a "lazy schema injection" mechanism. Instead of pre-loading potentially large tool schemas, the system now dynamically extracts and provides tool descriptions and parameter schemas as hints only when a WASM tool encounters an error. This approach helps the LLM correct its subsequent calls, reduces token usage by avoiding constant schema inclusion, and makes WASM tool integration more robust and cost-effective.

Highlights

  • Enhanced WASM Tool Error Handling: WASM tool errors now include dynamic hints (description and schema) to guide the LLM in retrying with correct parameters.
  • Optimized LLM Interaction: This approach avoids including large schemas in every request, reducing token usage and improving efficiency by only injecting hints when an error occurs.
  • Context Bloat Prevention: Introduced character limits for the injected hints: 500 characters for the description and 3000 characters for the schema.
  • Error Type Refactoring: The ToolReturnedError enum variant in src/tools/wasm/error.rs was refactored into a struct to accommodate the new message and hint fields.
  • New Hint Generation Logic: A build_tool_hint function was added in src/tools/wasm/wrapper.rs to call WASM module's description() and schema() exports and format the hint string.
Changelog
  • src/tools/wasm/error.rs
    • The ToolReturnedError enum variant was changed from a simple String to a struct containing message: String and hint: String.
    • The #[error(...)] attribute was updated to conditionally display the hint if it's not empty.
    • New unit tests (test_tool_returned_error_without_hint and test_tool_returned_error_with_hint) were added to verify the display logic of ToolReturnedError.
  • src/tools/wasm/wrapper.rs
    • The error handling logic within WasmToolWrapper::execute was modified to call a new build_tool_hint function when a response.error is present.
    • A new private function build_tool_hint was introduced. This function calls the WASM module's description() and schema() exports, formats them into a hint string, and applies character limits (HINT_DESC_MAX and HINT_SCHEMA_MAX).
    • Constants HINT_DESC_MAX (500) and HINT_SCHEMA_MAX (3000) were defined for the hint character limits.
Activity
  • The code was formatted using cargo fmt.
  • All clippy lints passed with cargo clippy --all --all-features.
  • Unit tests test_tool_returned_error_without_hint and test_tool_returned_error_with_hint were successfully run and verified.
  • Manual testing to trigger a WASM tool error and verify the LLM receives the hint is pending.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a useful feature for providing lazy hints to the LLM when a WASM tool fails, aiming to improve its ability to self-correct. However, the current implementation presents a high-severity prompt injection risk due to untrusted WASM tool exports being directly used as hints to the LLM, which requires sanitization for external services like LLMs. Additionally, the unsafe string truncation in the build_tool_hint function poses a denial-of-service risk with multi-byte UTF-8 characters, violating best practices for character-aware truncation. My review includes a comprehensive suggestion to address these issues by using a safe, character-aware truncation method and refactoring duplicated logic for better maintainability and security.

Comment thread src/tools/wasm/wrapper.rs
Comment on lines +660 to +688
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 {
hint.push_str(&desc[..HINT_DESC_MAX]);
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 {
hint.push_str(&schema[..HINT_SCHEMA_MAX]);
hint.push('…');
} else {
hint.push_str(&schema);
}
}
hint

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The build_tool_hint function is vulnerable to a high-severity prompt injection risk. It directly includes untrusted output from WASM module's description() and schema() exports into hints presented to the LLM. A malicious WASM tool could craft these strings to inject harmful instructions, potentially leading to unauthorized actions. To mitigate this, sanitize the output or use structured formats, as per Rule: 'Tool output previews sent to trusted local endpoints (e.g., TUI, web gateway) should not be sanitized. Sanitization should only be applied to data paths sent to external services, such as an LLM.'. Additionally, the current string truncation logic using &desc[..HINT_DESC_MAX] and &schema[..HINT_SCHEMA_MAX] is unsafe and can cause a panic with multi-byte UTF-8 characters. This violates Rule: 'When truncating a UTF-8 string, use character-aware methods (e.g., char_indices() or iterating over chars()) to avoid panics caused by slicing in the middle of a multi-byte character.' and Rule: 'When truncating a UTF-8 string at a byte boundary, walk backwards from the desired length until a valid character boundary is found using is_char_boundary to prevent panics.'. This truncation logic is also duplicated and should be refactored into a safe helper function for better maintainability and correctness, also aligning with Rule: 'Always truncate tool output for previews or status updates to a reasonable maximum length. This prevents excessive memory/bandwidth usage and reduces the risk of leaking sensitive information.'.

    let mut hint = String::new();

    fn append_truncated(buf: &mut String, s: &str, max_len: usize) {
        if s.len() > max_len {
            let mut end = max_len;
            while !s.is_char_boundary(end) {
                end -= 1;
            }
            buf.push_str(&s[..end]);
            buf.push('…');
        } else {
            buf.push_str(s);
        }
    }

    if !desc.is_empty() {
        hint.push_str("Description: ");
        append_truncated(&mut hint, &desc, HINT_DESC_MAX);
        hint.push('\n');
    }
    if !schema.is_empty() {
        hint.push_str("Parameters schema: ");
        append_truncated(&mut hint, &schema, HINT_SCHEMA_MAX);
    }
    hint
References
  1. Tool output previews sent to trusted local endpoints (e.g., TUI, web gateway) should not be sanitized. Sanitization should only be applied to data paths sent to external services, such as an LLM.
  2. When truncating a UTF-8 string, use character-aware methods (e.g., char_indices() or iterating over chars()) to avoid panics caused by slicing in the middle of a multi-byte character.
  3. When truncating a UTF-8 string at a byte boundary, walk backwards from the desired length until a valid character boundary is found using is_char_boundary to prevent panics.
  4. Always truncate tool output for previews or status updates to a reasonable maximum length. This prevents excessive memory/bandwidth usage and reduces the risk of leaking sensitive information.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two points here:

Prompt injection: This isn't new attack surface. The error path at worker.rs:986 already sends format!("Error: {}", e) as a tool result to the LLM — same as every other tool error (built-in or WASM). The WASM tool's execute() error message already flows unsanitized to the LLM on the error path. The hint (description/schema) is the tool's own metadata, no different from what would be in the tools array if we included it there.

UTF-8 panic: Valid catch. Fixed in 225d58e — now uses crate::util::floor_char_boundary(), which is the existing polyfill used in 6+ places across the codebase for exactly this purpose.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds lazy tool-schema/description injection for WASM tool failures: when a WASM tool returns an error, the host calls the module’s description() and schema() exports and appends them as a hint in the error string so the LLM can retry with correct arguments without including large schemas in every request.

Changes:

  • Inject a “tool usage hint” (description + parameters schema) into ToolReturnedError when a WASM tool returns an error.
  • Change WasmError::ToolReturnedError from a tuple variant to a struct variant { message, hint } and update its Display formatting.
  • Add tests validating that the hint is omitted when empty and included when present.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/tools/wasm/wrapper.rs Builds and attaches a description/schema hint on tool-level error responses.
src/tools/wasm/error.rs Updates the error variant to carry an optional hint and adds Display/tests for the new format.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/tools/wasm/wrapper.rs
Comment on lines +655 to +685
/// 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 {
hint.push_str(&desc[..HINT_DESC_MAX]);
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 {
hint.push_str(&schema[..HINT_SCHEMA_MAX]);
hint.push('…');
} else {
hint.push_str(&schema);

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

desc.len()/&desc[..HINT_DESC_MAX] treats the limit as bytes and can panic if HINT_DESC_MAX lands mid–UTF-8 codepoint (non-ASCII descriptions). Consider truncating on a char boundary (e.g., compute the byte offset via char_indices().nth(HINT_DESC_MAX) or walk back with is_char_boundary) and update the comment/constants to clarify whether the cap is bytes vs chars.

Suggested change
/// 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 {
hint.push_str(&desc[..HINT_DESC_MAX]);
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 {
hint.push_str(&schema[..HINT_SCHEMA_MAX]);
hint.push('…');
} else {
hint.push_str(&schema);
/// Truncate a UTF-8 string to at most `max_chars` Unicode scalar values.
/// Returns a slice of the original string and a flag indicating whether
/// truncation occurred.
fn truncate_utf8_to_char_boundary<'a>(s: &'a str, max_chars: usize) -> (&'a str, bool) {
if max_chars == 0 {
return ("", !s.is_empty());
}
let mut char_count = 0usize;
let mut byte_idx = s.len();
for (i, _) in s.char_indices() {
if char_count == max_chars {
byte_idx = i;
break;
}
char_count += 1;
}
if char_count <= max_chars && byte_idx == s.len() {
return (s, false);
}
(&s[..byte_idx], true)
}
/// 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`] characters, schema at
/// [`HINT_SCHEMA_MAX`] characters.
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: ");
let (desc_truncated, was_truncated) =
truncate_utf8_to_char_boundary(&desc, HINT_DESC_MAX);
hint.push_str(desc_truncated);
if was_truncated {
hint.push('…');
}
hint.push('\n');
}
if !schema.is_empty() {
hint.push_str("Parameters schema: ");
let (schema_truncated, was_truncated) =
truncate_utf8_to_char_boundary(&schema, HINT_SCHEMA_MAX);
hint.push_str(schema_truncated);
if was_truncated {
hint.push('…');

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 225d58e — using crate::util::floor_char_boundary() for both description and schema truncation.

Comment thread src/tools/wasm/wrapper.rs
Comment on lines +681 to +683
if schema.len() > HINT_SCHEMA_MAX {
hint.push_str(&schema[..HINT_SCHEMA_MAX]);
hint.push('…');

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

schema.len()/&schema[..HINT_SCHEMA_MAX] has the same UTF-8 boundary panic risk as the description truncation and also enforces a byte cap (despite docs saying "chars"). Please truncate safely at a char boundary (or rename to *_MAX_BYTES) so this can’t panic on non-ASCII schemas.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix applied — 225d58e.

Comment thread src/tools/wasm/error.rs
Comment on lines +71 to +79
/// 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,
},

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The #[error(...)] string builds the hint suffix with an inline if ... { format!(...) } expression, which makes the derive attribute hard to read/maintain. Consider storing the fully-formatted hint (including any leading newlines/labels) in the hint field when constructing the error (or moving the suffix formatting into a small helper) so the error message stays a simple "Tool error: {message}{hint}".

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline conditional is a standard thiserror pattern — it keeps the formatting co-located with the variant definition. Moving it elsewhere adds indirection without functional benefit. Leaving as-is.

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) <noreply@anthropic.com>
@henrypark133
henrypark133 merged commit fcb152e into main Mar 9, 2026
22 checks passed
@henrypark133
henrypark133 deleted the wasm-lazy-schema-hint branch March 9, 2026 18:27
This was referenced Mar 9, 2026
bkutasi pushed a commit to bkutasi/ironclaw that referenced this pull request Mar 28, 2026
* 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) <noreply@anthropic.com>

* 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 nearai#638.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
drchirag1991 pushed a commit to drchirag1991/ironclaw that referenced this pull request Apr 8, 2026
* 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) <noreply@anthropic.com>

* 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 nearai#638.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: tool/wasm WASM tool sandbox size: M 50-199 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants