fix: add tool_info schema discovery for WASM tools (#1086)

* fix: add tool_info schema discovery for WASM tools

* refactor: simplify WASM schema and hint state

* refactor: store tool_info registry reference as Weak
This commit is contained in:
Henry Park
2026-03-12 16:30:38 -07:00
committed by GitHub
parent c7dec64b2d
commit 8a60fa2d37
13 changed files with 658 additions and 189 deletions
+1
View File
@@ -290,6 +290,7 @@ impl AppBuilder {
Arc::new(ToolRegistry::new()) Arc::new(ToolRegistry::new())
}; };
tools.register_builtin_tools(); tools.register_builtin_tools();
tools.register_tool_info();
if let Some(ref ss) = self.secrets_store { if let Some(ref ss) = self.secrets_store {
tools.register_secrets_tools(Arc::clone(ss)); tools.register_secrets_tools(Arc::clone(ss));
+2
View File
@@ -15,6 +15,7 @@ pub mod secrets_tools;
pub(crate) mod shell; pub(crate) mod shell;
pub mod skill_tools; pub mod skill_tools;
mod time; mod time;
mod tool_info;
pub use echo::EchoTool; pub use echo::EchoTool;
pub use extension_tools::{ pub use extension_tools::{
@@ -39,6 +40,7 @@ pub use secrets_tools::{SecretDeleteTool, SecretListTool};
pub use shell::ShellTool; pub use shell::ShellTool;
pub use skill_tools::{SkillInstallTool, SkillListTool, SkillRemoveTool, SkillSearchTool}; pub use skill_tools::{SkillInstallTool, SkillListTool, SkillRemoveTool, SkillSearchTool};
pub use time::TimeTool; pub use time::TimeTool;
pub use tool_info::ToolInfoTool;
mod html_converter; mod html_converter;
pub mod image_analyze; pub mod image_analyze;
pub mod image_edit; pub mod image_edit;
+183
View File
@@ -0,0 +1,183 @@
//! On-demand tool discovery (like CLI `--help`).
//!
//! Two levels of detail:
//! - Default: name, description, parameter names (compact ~150 bytes)
//! - `include_schema: true`: adds the full typed JSON Schema
//!
//! Keeps the tools array compact (WASM tools use permissive schemas)
//! while allowing precise discovery when needed.
use std::sync::Weak;
use async_trait::async_trait;
use crate::context::JobContext;
use crate::tools::registry::ToolRegistry;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
pub struct ToolInfoTool {
registry: Weak<ToolRegistry>,
}
impl ToolInfoTool {
pub fn new(registry: Weak<ToolRegistry>) -> Self {
Self { registry }
}
}
#[async_trait]
impl Tool for ToolInfoTool {
fn name(&self) -> &str {
"tool_info"
}
fn description(&self) -> &str {
"Get info about any tool: description and parameter names. \
Set include_schema to true for the full typed parameter schema."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the tool to get info about"
},
"include_schema": {
"type": "boolean",
"description": "If true, include the full typed JSON Schema for parameters (larger response). Default: false.",
"default": false
}
},
"required": ["name"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = require_str(&params, "name")?;
let include_schema = params
.get("include_schema")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let registry = self.registry.upgrade().ok_or_else(|| {
ToolError::ExecutionFailed(
"tool registry is no longer available for tool_info".to_string(),
)
})?;
let tool = registry.get(name).await.ok_or_else(|| {
ToolError::InvalidParameters(format!("No tool named '{name}' is registered"))
})?;
let schema = tool.discovery_schema();
// Extract just param names from the schema's "properties" keys
let param_names: Vec<&str> = schema
.get("properties")
.and_then(|p| p.as_object())
.map(|props| props.keys().map(|k| k.as_str()).collect())
.unwrap_or_default();
let mut info = serde_json::json!({
"name": tool.name(),
"description": tool.description(),
"parameters": param_names,
});
if include_schema {
info["schema"] = schema;
}
Ok(ToolOutput::success(info, start.elapsed()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::builtin::EchoTool;
use std::sync::Arc;
#[tokio::test]
async fn test_tool_info_default_returns_param_names() {
let registry = Arc::new(ToolRegistry::new());
registry.register(Arc::new(EchoTool)).await;
let tool = ToolInfoTool::new(Arc::downgrade(&registry));
let ctx = JobContext::default();
let result = tool
.execute(serde_json::json!({"name": "echo"}), &ctx)
.await
.unwrap();
let info = &result.result;
assert_eq!(info["name"], "echo");
assert!(!info["description"].as_str().unwrap().is_empty());
// Default: parameters is an array of names, not the full schema
assert!(info["parameters"].is_array());
assert!(
info["parameters"]
.as_array()
.unwrap()
.iter()
.any(|v| v.as_str() == Some("message")),
"echo tool should have 'message' parameter: {:?}",
info["parameters"]
);
// No schema field by default
assert!(info.get("schema").is_none());
}
#[tokio::test]
async fn test_tool_info_with_schema() {
let registry = Arc::new(ToolRegistry::new());
registry.register(Arc::new(EchoTool)).await;
let tool = ToolInfoTool::new(Arc::downgrade(&registry));
let ctx = JobContext::default();
let result = tool
.execute(
serde_json::json!({"name": "echo", "include_schema": true}),
&ctx,
)
.await
.unwrap();
let info = &result.result;
assert_eq!(info["name"], "echo");
// With include_schema: true, schema field should be present
assert!(info["schema"].is_object());
assert!(info["schema"]["properties"].is_object());
}
#[tokio::test]
async fn test_tool_info_unknown_tool() {
let registry = Arc::new(ToolRegistry::new());
let tool = ToolInfoTool::new(Arc::downgrade(&registry));
let ctx = JobContext::default();
let result = tool
.execute(serde_json::json!({"name": "nonexistent"}), &ctx)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_tool_info_registry_dropped() {
let registry = Arc::new(ToolRegistry::new());
let tool = ToolInfoTool::new(Arc::downgrade(&registry));
drop(registry);
let ctx = JobContext::default();
let result = tool
.execute(serde_json::json!({"name": "echo"}), &ctx)
.await;
assert!(matches!(result, Err(ToolError::ExecutionFailed(_))));
}
}
+12
View File
@@ -75,6 +75,7 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[
"image_generate", "image_generate",
"image_edit", "image_edit",
"image_analyze", "image_analyze",
"tool_info",
]; ];
/// Registry of available tools. /// Registry of available tools.
@@ -245,6 +246,17 @@ impl ToolRegistry {
tracing::debug!("Registered {} built-in tools", self.count()); tracing::debug!("Registered {} built-in tools", self.count());
} }
/// Register the `tool_info` discovery tool.
///
/// Requires `Arc<Self>` so the tool can query the registry for other tools'
/// schemas at runtime. Call after `register_builtin_tools()`.
pub fn register_tool_info(self: &Arc<Self>) {
use crate::tools::builtin::ToolInfoTool;
let tool = ToolInfoTool::new(Arc::downgrade(self));
self.register_sync(Arc::new(tool));
tracing::debug!("Registered tool_info discovery tool");
}
/// Register only orchestrator-domain tools (safe for the main process). /// Register only orchestrator-domain tools (safe for the main process).
/// ///
/// This registers tools that don't touch the filesystem or run shell commands: /// This registers tools that don't touch the filesystem or run shell commands:
+11
View File
@@ -336,6 +336,17 @@ pub trait Tool: Send + Sync {
None None
} }
/// Full parameter schema for discovery and coercion purposes.
///
/// Unlike `parameters_schema()` (which may be permissive to keep the tools
/// array compact), this returns the complete typed schema. Used by the
/// `tool_info` built-in and by WASM parameter coercion.
///
/// Default: delegates to `parameters_schema()`.
fn discovery_schema(&self) -> serde_json::Value {
self.parameters_schema()
}
/// Get the tool schema for LLM function calling. /// Get the tool schema for LLM function calling.
fn schema(&self) -> ToolSchema { fn schema(&self) -> ToolSchema {
ToolSchema { ToolSchema {
+6 -84
View File
@@ -1,7 +1,5 @@
//! WASM sandbox error types. //! WASM sandbox error types.
use std::fmt;
use thiserror::Error; use thiserror::Error;
/// Errors that can occur during WASM tool execution. /// Errors that can occur during WASM tool execution.
@@ -68,13 +66,13 @@ pub enum WasmError {
Timeout(std::time::Duration), Timeout(std::time::Duration),
/// Component returned an error response. /// Component returned an error response.
/// When `hint` is non-empty it carries the tool's description and parameter /// When `hint` is non-empty it points the LLM to `tool_info` so it can
/// schema so the LLM can retry with correct arguments. /// fetch the tool's full parameter schema on demand.
#[error("Tool error: {message}{}", if hint.is_empty() { String::new() } else { format!("\n\nTool usage hint:\n{hint}") })] #[error("Tool error: {message}{}", if hint.is_empty() { String::new() } else { format!("\n\nTool usage hint:\n{hint}") })]
ToolReturnedError { ToolReturnedError {
/// The error message from the WASM tool. /// The error message from the WASM tool.
message: String, message: String,
/// Optional description + schema hint (empty when unavailable). /// Optional retry hint (empty when unavailable).
hint: String, hint: String,
}, },
@@ -99,73 +97,9 @@ impl From<WasmError> for crate::tools::ToolError {
} }
} }
/// 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)] #[cfg(test)]
mod tests { mod tests {
use crate::tools::wasm::error::{TrapCode, TrapInfo, WasmError}; use crate::tools::wasm::error::WasmError;
#[test] #[test]
fn test_error_display() { fn test_error_display() {
@@ -180,17 +114,6 @@ mod tests {
assert!(err.to_string().contains("10000000")); 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] #[test]
fn test_conversion_to_tool_error() { fn test_conversion_to_tool_error() {
let wasm_err = WasmError::Trapped("test trap".to_string()); let wasm_err = WasmError::Trapped("test trap".to_string());
@@ -218,12 +141,11 @@ mod tests {
fn test_tool_returned_error_with_hint() { fn test_tool_returned_error_with_hint() {
let err = WasmError::ToolReturnedError { let err = WasmError::ToolReturnedError {
message: "unknown action: foobar".to_string(), message: "unknown action: foobar".to_string(),
hint: "Description: Gmail tool\nParameters schema: {\"type\":\"object\"}".to_string(), hint: "Tip: call tool_info(name: \"gmail\", include_schema: true) for the full parameter schema.".to_string(),
}; };
let display = err.to_string(); let display = err.to_string();
assert!(display.contains("unknown action: foobar")); assert!(display.contains("unknown action: foobar"));
assert!(display.contains("Tool usage hint")); assert!(display.contains("Tool usage hint"));
assert!(display.contains("Gmail tool")); assert!(display.contains("tool_info"));
assert!(display.contains("Parameters schema"));
} }
} }
-8
View File
@@ -67,14 +67,8 @@ pub struct WasmResourceLimiter {
memory_used: u64, memory_used: u64,
/// Maximum tables allowed. /// Maximum tables allowed.
max_tables: u32, max_tables: u32,
/// Current table count.
#[allow(dead_code)] // Reserved for table limit enforcement
tables_created: u32,
/// Maximum instances allowed. /// Maximum instances allowed.
max_instances: u32, max_instances: u32,
/// Current instance count.
#[allow(dead_code)] // Reserved for instance limit enforcement
instances_created: u32,
} }
impl WasmResourceLimiter { impl WasmResourceLimiter {
@@ -87,9 +81,7 @@ impl WasmResourceLimiter {
memory_limit, memory_limit,
memory_used: 0, memory_used: 0,
max_tables: 10, max_tables: 10,
tables_created: 0,
max_instances: 10, // Component model needs multiple instances for WASI max_instances: 10, // Component model needs multiple instances for WASI
instances_created: 0,
} }
} }
+1 -1
View File
@@ -96,7 +96,7 @@ pub(crate) mod storage;
mod wrapper; mod wrapper;
// Core types // Core types
pub use error::{TrapCode, TrapInfo, WasmError}; pub use error::WasmError;
pub use host::{HostState, LogEntry, LogLevel}; pub use host::{HostState, LogEntry, LogLevel};
pub use limits::{ pub use limits::{
DEFAULT_FUEL_LIMIT, DEFAULT_MEMORY_LIMIT, DEFAULT_TIMEOUT, FuelConfig, ResourceLimits, DEFAULT_FUEL_LIMIT, DEFAULT_MEMORY_LIMIT, DEFAULT_TIMEOUT, FuelConfig, ResourceLimits,
+26 -41
View File
@@ -123,7 +123,9 @@ pub struct PreparedModule {
pub name: String, pub name: String,
/// Tool description (cached from component). /// Tool description (cached from component).
pub description: String, pub description: String,
/// Parameter schema JSON (cached from component). /// Full parameter schema JSON extracted from the component.
/// Used for discovery and coercion, not necessarily for the compact
/// schema advertised in the main tools array.
pub schema: serde_json::Value, pub schema: serde_json::Value,
/// Pre-compiled component (cheaply cloneable via internal Arc). /// Pre-compiled component (cheaply cloneable via internal Arc).
component: wasmtime::component::Component, component: wasmtime::component::Component,
@@ -265,11 +267,29 @@ impl WasmToolRuntime {
let component = wasmtime::component::Component::new(&engine, &wasm_bytes) let component = wasmtime::component::Component::new(&engine, &wasm_bytes)
.map_err(|e| WasmError::CompilationFailed(e.to_string()))?; .map_err(|e| WasmError::CompilationFailed(e.to_string()))?;
// We need to instantiate briefly to extract metadata. // Briefly instantiate to extract metadata (description + schema)
// In a full implementation, we'd use WIT bindgen to get typed access. // from the tool's exports, analogous to MCP's list_tools().
// For now, we extract what we can from the component. let effective_limits = limits.clone().unwrap_or(default_limits.clone());
let description = extract_tool_description(&engine, &component)?; let (description, schema) = crate::tools::wasm::wrapper::extract_wasm_metadata(
let schema = extract_tool_schema(&engine, &component)?; &engine,
&component,
&effective_limits,
)
.unwrap_or_else(|e| {
tracing::warn!(
name = %name,
error = %e,
"WASM metadata extraction failed, using fallbacks"
);
(
"WASM sandboxed tool".to_string(),
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
}),
)
});
Ok::<_, WasmError>(PreparedModule { Ok::<_, WasmError>(PreparedModule {
name: name.clone(), name: name.clone(),
@@ -321,41 +341,6 @@ impl WasmToolRuntime {
} }
} }
/// Extract tool description from a compiled component.
///
/// Returns a generic fallback. Callers should prefer loading the description
/// from the sidecar `*.capabilities.json` file and overriding via
/// `WasmToolWrapper::with_description()` or the `WasmToolRegistration::description` field.
fn extract_tool_description(
_engine: &Engine,
_component: &wasmtime::component::Component,
) -> Result<String, WasmError> {
// WIT bindgen extraction is not yet implemented (see TODO #4 in CLAUDE.md).
// Real descriptions come from the capabilities.json sidecar file, which is
// loaded by the WasmToolLoader and passed as an override at registration time.
Ok("WASM sandboxed tool".to_string())
}
/// Extract tool parameter schema from a compiled component.
///
/// Returns a permissive fallback that accepts any JSON object. Callers should
/// prefer loading the schema from the sidecar `*.capabilities.json` file and
/// overriding via `WasmToolWrapper::with_schema()` or the
/// `WasmToolRegistration::schema` field.
fn extract_tool_schema(
_engine: &Engine,
_component: &wasmtime::component::Component,
) -> Result<serde_json::Value, WasmError> {
// WIT bindgen extraction is not yet implemented (see TODO #4 in CLAUDE.md).
// Real schemas come from the capabilities.json sidecar file, which is
// loaded by the WasmToolLoader and passed as an override at registration time.
Ok(serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
}))
}
impl std::fmt::Debug for WasmToolRuntime { impl std::fmt::Debug for WasmToolRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WasmToolRuntime") f.debug_struct("WasmToolRuntime")
+245 -55
View File
@@ -465,8 +465,8 @@ pub struct WasmToolWrapper {
capabilities: Capabilities, capabilities: Capabilities,
/// Cached description (from PreparedModule or override). /// Cached description (from PreparedModule or override).
description: String, description: String,
/// Cached schema (from PreparedModule or override). /// Compact and discovery schemas for this tool.
schema: serde_json::Value, schemas: WasmToolSchemas,
/// Injected credentials for HTTP requests (e.g., OAuth tokens). /// Injected credentials for HTTP requests (e.g., OAuth tokens).
/// Keys are placeholder names like "GOOGLE_ACCESS_TOKEN". /// Keys are placeholder names like "GOOGLE_ACCESS_TOKEN".
credentials: HashMap<String, String>, credentials: HashMap<String, String>,
@@ -477,6 +477,79 @@ pub struct WasmToolWrapper {
oauth_refresh: Option<OAuthRefreshConfig>, oauth_refresh: Option<OAuthRefreshConfig>,
} }
#[derive(Debug, Clone)]
struct WasmToolSchemas {
/// Compact schema advertised in the main tools array.
///
/// This stays permissive by default to avoid serializing full exported
/// WASM schemas on every LLM call. Sidecars can override it explicitly.
advertised: serde_json::Value,
/// Full schema available for discovery and coercion.
///
/// Seeded from the WASM `schema()` export at registration time, unless a
/// sidecar explicitly overrides it.
discovery: serde_json::Value,
}
impl WasmToolSchemas {
fn permissive_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
})
}
fn is_permissive_schema(schema: &serde_json::Value) -> bool {
schema
.get("properties")
.and_then(|p| p.as_object())
.is_none_or(|p| p.is_empty())
}
fn new(discovery: serde_json::Value) -> Self {
Self {
advertised: Self::permissive_schema(),
discovery,
}
}
fn with_override(&self, schema: serde_json::Value) -> Self {
Self {
advertised: schema.clone(),
discovery: schema,
}
}
fn is_advertised_permissive(&self) -> bool {
Self::is_permissive_schema(&self.advertised)
}
fn advertised(&self) -> serde_json::Value {
self.advertised.clone()
}
fn discovery(&self) -> serde_json::Value {
self.discovery.clone()
}
fn effective_for_coercion(
&self,
tool_iface: &wit_tool::Guest,
store: &mut Store<StoreData>,
) -> serde_json::Value {
if !Self::is_permissive_schema(&self.discovery) {
return self.discovery.clone();
}
tool_iface
.call_schema(store)
.ok()
.and_then(|schema_str| serde_json::from_str::<serde_json::Value>(&schema_str).ok())
.unwrap_or_else(|| self.discovery.clone())
}
}
impl WasmToolWrapper { impl WasmToolWrapper {
/// Create a new WASM tool wrapper. /// Create a new WASM tool wrapper.
pub fn new( pub fn new(
@@ -484,30 +557,54 @@ impl WasmToolWrapper {
prepared: Arc<PreparedModule>, prepared: Arc<PreparedModule>,
capabilities: Capabilities, capabilities: Capabilities,
) -> Self { ) -> Self {
Self { let mut wrapper = Self {
description: prepared.description.clone(), description: prepared.description.clone(),
schema: prepared.schema.clone(), schemas: WasmToolSchemas::new(prepared.schema.clone()),
runtime, runtime,
prepared, prepared,
capabilities, capabilities,
credentials: HashMap::new(), credentials: HashMap::new(),
secrets_store: None, secrets_store: None,
oauth_refresh: None, oauth_refresh: None,
} };
wrapper.append_schema_hint_if_permissive();
wrapper
} }
/// Override the tool description. /// Override the tool description.
pub fn with_description(mut self, description: impl Into<String>) -> Self { pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into(); self.description = description.into();
self.append_schema_hint_if_permissive();
self self
} }
/// Override the parameter schema. /// Override the parameter schema.
pub fn with_schema(mut self, schema: serde_json::Value) -> Self { pub fn with_schema(mut self, schema: serde_json::Value) -> Self {
self.schema = schema; self.schemas = self.schemas.with_override(schema);
self.strip_schema_hint();
self.append_schema_hint_if_permissive();
self self
} }
/// Append a tool_info hint to the description when the schema is permissive
/// (no typed properties), so the LLM knows to call tool_info for the full schema.
fn append_schema_hint_if_permissive(&mut self) {
if self.schemas.is_advertised_permissive() && !self.description.contains("tool_info") {
self.description
.push_str(" (call tool_info for parameter schema)");
}
}
/// Remove the tool_info hint from the description (e.g. after with_schema adds real types).
fn strip_schema_hint(&mut self) {
if let Some(pos) = self
.description
.find(" (call tool_info for parameter schema)")
{
self.description.truncate(pos);
}
}
/// Set credentials for HTTP request placeholder injection. /// Set credentials for HTTP request placeholder injection.
pub fn with_credentials(mut self, credentials: HashMap<String, String>) -> Self { pub fn with_credentials(mut self, credentials: HashMap<String, String>) -> Self {
self.credentials = credentials; self.credentials = credentials;
@@ -615,9 +712,17 @@ impl WasmToolWrapper {
} }
})?; })?;
// Get typed interface — used for execute and error hints.
let tool_iface = instance.near_agent_tool();
// Determine effective schema for type coercion.
// Prefer the registration-time discovery schema when typed; otherwise
// try the WASM export transiently for this invocation only.
let effective_schema = self.schemas.effective_for_coercion(tool_iface, &mut store);
// Coerce string-encoded values to their schema-declared types. // Coerce string-encoded values to their schema-declared types.
// LLMs frequently pass numeric values as strings (e.g. "5" instead of 5). // LLMs frequently pass numeric values as strings (e.g. "5" instead of 5).
let params = coerce_params_to_schema(params, &self.schema); let params = coerce_params_to_schema(params, &effective_schema);
// Prepare the request // Prepare the request
let params_json = serde_json::to_string(&params) let params_json = serde_json::to_string(&params)
@@ -629,7 +734,6 @@ impl WasmToolWrapper {
}; };
// Call execute using the generated typed interface // Call execute using the generated typed interface
let tool_iface = instance.near_agent_tool();
let response = tool_iface.call_execute(&mut store, &request).map_err(|e| { let response = tool_iface.call_execute(&mut store, &request).map_err(|e| {
let error_str = e.to_string(); let error_str = e.to_string();
if error_str.contains("out of fuel") { if error_str.contains("out of fuel") {
@@ -644,12 +748,13 @@ impl WasmToolWrapper {
// Get logs from host state // Get logs from host state
let logs = store.data_mut().host_state.take_logs(); let logs = store.data_mut().host_state.take_logs();
// Check for tool-level error — on failure, call the WASM module's // Check for tool-level error — point the LLM to tool_info for the
// description() and schema() exports so the LLM can retry with the // full schema instead of dumping ~3.5KB inline.
// correct parameters without us having to include the (large) schema
// in every request's tools array.
if let Some(err) = response.error { if let Some(err) = response.error {
let hint = build_tool_hint(tool_iface, &mut store); let hint = format!(
"Tip: call tool_info(name: \"{}\", include_schema: true) for the full parameter schema.",
self.prepared.name
);
return Err(WasmError::ToolReturnedError { message: err, hint }); return Err(WasmError::ToolReturnedError { message: err, hint });
} }
@@ -658,47 +763,55 @@ impl WasmToolWrapper {
} }
} }
/// Maximum characters for the description portion of a tool hint. /// Extract metadata (description + schema) from a WASM tool by briefly
const HINT_DESC_MAX: usize = 500; /// instantiating it and calling its `description()` and `schema()` exports.
/// Maximum characters for the schema portion of a tool hint. /// Analogous to MCP's `list_tools()` — discovers tool capabilities at load time.
const HINT_SCHEMA_MAX: usize = 3000; ///
/// Falls back to generic description and permissive schema on failure.
pub(super) fn extract_wasm_metadata(
engine: &wasmtime::Engine,
component: &wasmtime::component::Component,
limits: &ResourceLimits,
) -> Result<(String, serde_json::Value), WasmError> {
let store_data = StoreData::new(
limits.memory_bytes,
Capabilities::default(),
HashMap::new(),
vec![],
);
let mut store = Store::new(engine, store_data);
/// Call the WASM module's `description()` and `schema()` exports to build a // Configure fuel + epoch deadline so extraction can't hang
/// hint string. Returns an empty string if both calls fail or return empty. if let Err(e) = store.set_fuel(limits.fuel) {
/// Description is capped at [`HINT_DESC_MAX`] chars, schema at tracing::debug!("Fuel not enabled for metadata extraction: {e}");
/// [`HINT_SCHEMA_MAX`] chars. }
fn build_tool_hint(tool_iface: &wit_tool::Guest, store: &mut Store<StoreData>) -> String { store.epoch_deadline_trap();
let desc = tool_iface let ticks = (limits.timeout.as_millis() / EPOCH_TICK_INTERVAL.as_millis()).max(1) as u64;
.call_description(&mut *store) store.set_epoch_deadline(ticks);
store.limiter(|data| &mut data.limiter);
// Instantiate with minimal linker
let mut linker = Linker::new(engine);
WasmToolWrapper::add_host_functions(&mut linker)?;
let instance = SandboxedTool::instantiate(&mut store, component, &linker)
.map_err(|e| WasmError::InstantiationFailed(e.to_string()))?;
let tool_iface = instance.near_agent_tool();
// Extract description (fall back to generic)
let description = tool_iface
.call_description(&mut store)
.unwrap_or_else(|_| "WASM sandboxed tool".to_string());
// Extract and parse schema (fall back to permissive)
let schema = tool_iface
.call_schema(&mut store)
.ok() .ok()
.unwrap_or_default(); .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
let schema = tool_iface.call_schema(&mut *store).ok().unwrap_or_default(); .unwrap_or_else(|| {
if desc.is_empty() && schema.is_empty() { serde_json::json!({"type": "object", "properties": {}, "additionalProperties": true})
return String::new(); });
}
let mut hint = String::new(); Ok((description, schema))
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] #[async_trait]
@@ -712,7 +825,11 @@ impl Tool for WasmToolWrapper {
} }
fn parameters_schema(&self) -> serde_json::Value { fn parameters_schema(&self) -> serde_json::Value {
self.schema.clone() self.schemas.advertised()
}
fn discovery_schema(&self) -> serde_json::Value {
self.schemas.discovery()
} }
async fn execute( async fn execute(
@@ -749,7 +866,7 @@ impl Tool for WasmToolWrapper {
let prepared = Arc::clone(&self.prepared); let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone(); let capabilities = self.capabilities.clone();
let description = self.description.clone(); let description = self.description.clone();
let schema = self.schema.clone(); let schemas = self.schemas.clone();
let credentials = self.credentials.clone(); let credentials = self.credentials.clone();
// Execute in blocking task with timeout // Execute in blocking task with timeout
@@ -759,7 +876,7 @@ impl Tool for WasmToolWrapper {
prepared, prepared,
capabilities, capabilities,
description, description,
schema, schemas,
credentials, credentials,
secrets_store: None, // Not needed in blocking task secrets_store: None, // Not needed in blocking task
oauth_refresh: None, // Already used above for pre-refresh oauth_refresh: None, // Already used above for pre-refresh
@@ -1232,6 +1349,7 @@ mod tests {
TEST_GOOGLE_OAUTH_TOKEN, TEST_OAUTH_CLIENT_ID, TEST_OAUTH_CLIENT_SECRET, TEST_GOOGLE_OAUTH_TOKEN, TEST_OAUTH_CLIENT_ID, TEST_OAUTH_CLIENT_SECRET,
test_secrets_store, test_secrets_store,
}; };
use crate::tools::tool::Tool;
use crate::tools::wasm::capabilities::Capabilities; use crate::tools::wasm::capabilities::Capabilities;
use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime}; use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime};
@@ -1246,6 +1364,61 @@ mod tests {
assert!(runtime.config().fuel_config.enabled); assert!(runtime.config().fuel_config.enabled);
} }
#[tokio::test]
async fn test_advertised_schema_stays_permissive_until_sidecar_override() {
let discovery_schema = serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"limit": { "type": "integer" }
},
"required": ["query"]
});
let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::for_testing()).unwrap());
let prepared = runtime
.prepare("search", b"\0asm\x0d\0\x01\0", None)
.await
.unwrap();
let mut wrapper =
super::WasmToolWrapper::new(Arc::clone(&runtime), prepared, Capabilities::default());
wrapper.schemas = super::WasmToolSchemas::new(discovery_schema.clone());
wrapper.description = "Search documents".to_string();
wrapper.append_schema_hint_if_permissive();
assert_eq!(
wrapper.parameters_schema(),
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
})
);
assert_eq!(wrapper.discovery_schema(), discovery_schema);
assert!(wrapper.description().contains("tool_info"));
let wrapper = wrapper.with_schema(serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" }
},
"required": ["query"]
}));
assert_eq!(
wrapper.parameters_schema(),
serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" }
},
"required": ["query"]
})
);
assert_eq!(wrapper.discovery_schema(), wrapper.parameters_schema());
assert!(!wrapper.description().contains("tool_info"));
}
#[test] #[test]
fn test_capabilities_default() { fn test_capabilities_default() {
let caps = Capabilities::default(); let caps = Capabilities::default();
@@ -1788,6 +1961,23 @@ mod tests {
assert_eq!(result["count"], serde_json::json!("not-a-number")); assert_eq!(result["count"], serde_json::json!("not-a-number"));
} }
/// Regression: permissive fallback schema (empty properties) must NOT coerce.
/// This documents the bug where WASM tools with no sidecar `parameters` field
/// got the permissive fallback, causing coercion to be a no-op and LLM-provided
/// string integers to reach the WASM tool un-coerced.
#[test]
fn test_coerce_noop_with_permissive_schema() {
let permissive = serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
});
let params = serde_json::json!({"query": "test", "count": "10"});
let result = super::coerce_params_to_schema(params, &permissive);
// With empty properties, no coercion happens — string stays string
assert_eq!(result["count"], serde_json::json!("10"));
}
/// Regression test: leak scan must run on raw headers (before credential /// Regression test: leak scan must run on raw headers (before credential
/// injection), not after. If it ran post-injection, the host-injected /// injection), not after. If it ran post-injection, the host-injected
/// Slack bot token (`xoxb-...`) would trigger a Block and reject the /// Slack bot token (`xoxb-...`) would trigger a Block and reject the
+86
View File
@@ -457,4 +457,90 @@ mod tests {
rig.shutdown(); rig.shutdown();
} }
// -----------------------------------------------------------------------
// Test: tool_info_discovery (two-level detail)
// -----------------------------------------------------------------------
// Verifies the tool_info built-in returns:
// - Default (no include_schema): name, description, parameter names array
// - With include_schema: true: adds full typed JSON Schema
#[tokio::test]
async fn tool_info_discovery() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/tool_info_discovery.json"
))
.expect("failed to load tool_info_discovery.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("What is the schema for the echo and time tools?")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
// tool_info should have been called twice (echo + time), both succeeding.
let completed = rig.tool_calls_completed();
let tool_info_calls: Vec<_> = completed.iter().filter(|(n, _)| n == "tool_info").collect();
assert_eq!(
tool_info_calls.len(),
2,
"Expected 2 tool_info calls, got {tool_info_calls:?}"
);
assert!(
tool_info_calls.iter().all(|(_, ok)| *ok),
"All tool_info calls should succeed: {tool_info_calls:?}"
);
// Verify the results contain expected fields.
let results = rig.tool_results();
let info_results: Vec<_> = results.iter().filter(|(n, _)| n == "tool_info").collect();
// First call was for "echo" (default, no include_schema) — result should
// contain "echo" and "parameters" as an array of names (not full schema).
let echo_result = info_results
.iter()
.find(|(_, preview)| preview.contains("echo"))
.expect("tool_info result should contain 'echo'");
assert!(
echo_result.1.contains("message"),
"echo default result should list 'message' parameter name: {:?}",
echo_result.1
);
// Default mode should NOT include the full "schema" key
let echo_json: serde_json::Value = serde_json::from_str(&echo_result.1)
.expect("echo tool_info result should be valid JSON");
assert!(
echo_json.get("schema").is_none(),
"Default tool_info should not include schema field: {:?}",
echo_result.1
);
// Second call was for "time" with include_schema: true — result should
// contain "time", "schema" field with full object.
let time_result = info_results
.iter()
.find(|(_, preview)| preview.contains("time"))
.expect("tool_info result should contain 'time'");
let time_json: serde_json::Value = serde_json::from_str(&time_result.1)
.expect("time tool_info result should be valid JSON");
assert!(
time_json.get("schema").is_some(),
"include_schema: true should include schema field: {:?}",
time_result.1
);
assert!(
time_json["schema"]["properties"].is_object(),
"schema should have properties: {:?}",
time_result.1
);
rig.shutdown();
}
} }
@@ -0,0 +1,50 @@
{
"model_name": "test-tool-info-discovery",
"expects": {
"tools_used": ["tool_info"],
"all_tools_succeeded": true,
"min_responses": 1,
"tool_results_contain": {
"tool_info": "echo"
}
},
"steps": [
{
"request_hint": { "last_user_message_contains": "schema" },
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_tool_info_echo",
"name": "tool_info",
"arguments": { "name": "echo" }
}
],
"input_tokens": 100,
"output_tokens": 20
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_tool_info_time",
"name": "tool_info",
"arguments": { "name": "time", "include_schema": true }
}
],
"input_tokens": 200,
"output_tokens": 20
}
},
{
"response": {
"type": "text",
"content": "I found the info for both tools. The echo tool has a 'message' parameter. The time tool accepts an 'operation' parameter with options like 'now', 'parse', and 'diff'.",
"input_tokens": 400,
"output_tokens": 40
}
}
]
}
@@ -1,6 +1,41 @@
{ {
"version": "0.2.0", "version": "0.2.0",
"wit_version": "0.3.0", "wit_version": "0.3.0",
"description": "Search the web using Brave Search. Returns titles, URLs, descriptions, and publication dates for matching web pages. Supports filtering by country, language, and freshness. Authentication is handled via the 'brave_api_key' secret injected by the host.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query to look up on the web"
},
"count": {
"type": "integer",
"description": "Number of results to return (1-20, default 5)",
"minimum": 1,
"maximum": 20,
"default": 5
},
"country": {
"type": "string",
"description": "2-letter uppercase country code to bias results (e.g. 'US', 'DE', 'JP')"
},
"search_lang": {
"type": "string",
"description": "2-letter lowercase language code for search results (e.g. 'en', 'de', 'fr')"
},
"ui_lang": {
"type": "string",
"description": "Locale in language-region format (e.g. 'en-US', 'de-DE')"
},
"freshness": {
"type": "string",
"description": "Filter by discovery time: 'pd' (past day), 'pw' (past week), 'pm' (past month), 'py' (past year), or date range 'YYYY-MM-DDtoYYYY-MM-DD'"
}
},
"required": ["query"],
"additionalProperties": false
},
"capabilities": { "capabilities": {
"http": { "http": {
"allowlist": [ "allowlist": [