From aec42aceda24725a96d9b0a1578aedc325bc81f9 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 5 Feb 2026 00:37:19 -0800 Subject: [PATCH] Fix Telegram Markdown formatting and clarify tool/memory distinctions - Add escape_telegram_markdown() to handle underscores in tool names (e.g., build_software was breaking Telegram's Markdown parser) - Use Telegram-compatible *bold* syntax instead of **bold** - Clarify workspace memory vs filesystem tool descriptions to prevent LLM from using read_file on memory_tree paths - Update build_software to strongly prefer Rust WASM for agent tools - Rewrite WASM tool template to use Component Model with wit_bindgen instead of outdated extern "C" approach Co-Authored-By: Claude Opus 4.5 --- src/agent/agent_loop.rs | 42 ++++++-- src/agent/heartbeat.rs | 2 +- src/tools/builder/core.rs | 209 ++++++++++++++++++++++++------------ src/tools/builtin/file.rs | 12 ++- src/tools/builtin/memory.rs | 11 +- 5 files changed, 191 insertions(+), 85 deletions(-) diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index e5d749a8..3c9a3ca4 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -2,6 +2,29 @@ use std::sync::Arc; +/// Escape special characters for Telegram's legacy Markdown. +/// +/// In Telegram's Markdown mode, these characters have special meaning: +/// - `_` starts/ends italic +/// - `*` starts/ends bold +/// - `` ` `` starts/ends code +/// - `[` starts a link +/// +/// We escape them with backslash so dynamic content doesn't break formatting. +fn escape_telegram_markdown(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '_' | '*' | '`' | '[' => { + result.push('\\'); + result.push(c); + } + _ => result.push(c), + } + } + result +} + use futures::StreamExt; use tokio::sync::Mutex; use uuid::Uuid; @@ -341,17 +364,22 @@ impl Agent { } else { params_preview }; + // Escape Markdown special chars in dynamic values to avoid breaking + // Telegram's Markdown parser (underscores, asterisks, backticks, brackets) + let tool_name_escaped = escape_telegram_markdown(&tool_name); + let description_escaped = escape_telegram_markdown(&description); + // Params go inside a code block, so no escaping needed there Ok(Some(format!( "🔒 Tool requires approval:\n\n\ - **Tool:** {}\n\ - **Description:** {}\n\ - **Parameters:** ```\n{}\n```\n\n\ + *Tool:* {}\n\ + *Description:* {}\n\ + *Parameters:*\n```\n{}\n```\n\n\ Reply with:\n\ - - `yes` or `approve` to allow this tool\n\ - - `always` to always allow this tool in this session\n\ - - `no` or `deny` to reject\n\n\ + • yes or approve to allow this tool\n\ + • always to always allow this tool in this session\n\ + • no or deny to reject\n\n\ Request ID: {}", - tool_name, description, params_truncated, request_id + tool_name_escaped, description_escaped, params_truncated, request_id ))) } } diff --git a/src/agent/heartbeat.rs b/src/agent/heartbeat.rs index a89cf75e..e6f25379 100644 --- a/src/agent/heartbeat.rs +++ b/src/agent/heartbeat.rs @@ -244,7 +244,7 @@ impl HeartbeatRunner { }; let response = OutgoingResponse { - content: format!("🔔 **Heartbeat Alert**\n\n{}", message), + content: format!("🔔 *Heartbeat Alert*\n\n{}", message), thread_id: None, metadata: serde_json::json!({ "source": "heartbeat", diff --git a/src/tools/builder/core.rs b/src/tools/builder/core.rs index 447e1979..839ff23f 100644 --- a/src/tools/builder/core.rs +++ b/src/tools/builder/core.rs @@ -343,105 +343,167 @@ Language: {language:?} ## WASM Tool Requirements -You are building a WASM tool for an autonomous agent. The tool must: +You are building a WASM Component tool for an autonomous agent using the WASM Component Model. +The tool MUST use `wit_bindgen` and `cargo-component` to build. -1. **Implement the guest interface** - Export a `run` function that takes JSON input and returns JSON output +## Available Host Functions (from WIT interface) -2. **Use only available host functions**: - - `host_log(level, message)` - Log messages (levels: debug, info, warn, error) - - `host_time()` - Get current Unix timestamp - - `host_http_request(method, url, headers, body)` - Make HTTP requests (if capability granted) - - `host_workspace_read(path)` - Read from workspace (if capability granted) - - `host_workspace_write(path, content)` - Write to workspace (if capability granted) - - `host_get_secret(name)` - Get injected secret (if capability granted) - -3. **Handle errors gracefully** - Return error results, never panic - -4. **Be deterministic** - Same input should produce same output (except for time/HTTP) - -## WASM Tool Template (Rust) +The host provides these functions via `near::agent::host`: ```rust -// Cargo.toml +// Logging (always available) +host::log(level: LogLevel, message: &str); // LogLevel: Trace, Debug, Info, Warn, Error + +// Time (always available) +host::now_millis() -> u64; // Unix timestamp in milliseconds + +// Workspace (if capability granted) +host::workspace_read(path: &str) -> Option; + +// HTTP (if capability granted) +host::http_request(method: &str, url: &str, headers_json: &str, body: Option>) + -> Result; +// HttpResponse has: status: u16, headers_json: String, body: Vec + +// Tool invocation (if capability granted) +host::tool_invoke(alias: &str, params_json: &str) -> Result; + +// Secrets (if capability granted) - can only CHECK existence, not read values +host::secret_exists(name: &str) -> bool; +``` + +## Project Structure + +``` +my_tool/ +├── Cargo.toml +├── wit/ +│ └── tool.wit # Copy from agent's wit/tool.wit +└── src/ + └── lib.rs +``` + +## Cargo.toml Template + +```toml [package] -name = "tool_name" +name = "my_tool" version = "0.1.0" -edition = "2024" +edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] +wit-bindgen = "0.41" serde = { version = "1", features = ["derive"] } serde_json = "1" +``` + +## src/lib.rs Template + +```rust +// Generate bindings from the WIT interface +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "wit/tool.wit", +}); -// src/lib.rs use serde::{Deserialize, Serialize}; +use exports::near::agent::tool::{Guest, Request, Response}; +use near::agent::host::{self, LogLevel}; +// Your input/output types #[derive(Deserialize)] -struct Input { - // Define your input parameters +struct MyInput { + // Define parameters here } #[derive(Serialize)] -struct Output { - // Define your output structure +struct MyOutput { + // Define output here } -// Host function imports -extern "C" { - fn host_log(level: i32, ptr: *const u8, len: usize); +struct MyTool; + +impl Guest for MyTool { + fn execute(req: Request) -> Response { + // Parse input + let input: MyInput = match serde_json::from_str(&req.params) { + Ok(i) => i, + Err(e) => return Response { + output: None, + error: Some(format!("Invalid input: {}", e)), + }, + }; + + host::log(LogLevel::Info, &format!("Processing request...")); + + // Your implementation here + let output = MyOutput { /* ... */ }; + + // Return success + Response { + output: Some(serde_json::to_string(&output).unwrap()), + error: None, + } + } + + fn schema() -> String { + serde_json::json!({ + "type": "object", + "properties": { + // Define your JSON Schema here + }, + "required": [] + }).to_string() + } + + fn description() -> String { + "Description of what this tool does".to_string() + } } -fn log_info(msg: &str) { - unsafe { host_log(1, msg.as_ptr(), msg.len()); } -} - -#[no_mangle] -pub extern "C" fn run(input_ptr: *const u8, input_len: usize) -> *mut u8 { - // Parse input - let input_bytes = unsafe { std::slice::from_raw_parts(input_ptr, input_len) }; - let input: Input = match serde_json::from_slice(input_bytes) { - Ok(i) => i, - Err(e) => return error_response(&format!("Invalid input: {}", e)), - }; - - // Your implementation here - let output = Output { /* ... */ }; - - // Return output - let json = serde_json::to_vec(&output).unwrap(); - let ptr = json.as_ptr() as *mut u8; - std::mem::forget(json); - ptr -} - -fn error_response(msg: &str) -> *mut u8 { - let json = serde_json::json!({"error": msg}).to_string(); - let ptr = json.as_ptr() as *mut u8; - std::mem::forget(json); - ptr -} +export!(MyTool); ``` -## Build Commands for WASM +## Build Commands ```bash -# Add WASM target -rustup target add wasm32-wasip2 +# Install cargo-component (one time) +cargo install cargo-component -# Build -cargo build --target wasm32-wasip2 --release +# Build the WASM component +cargo component build --release -# Output will be at: target/wasm32-wasip2/release/tool_name.wasm +# Output: target/wasm32-wasip2/release/my_tool.wasm ``` -## Tool Capabilities +## Capabilities File (my_tool.capabilities.json) -When defining capabilities for your tool, specify which host functions it needs: -- `http`: Allows HTTP requests to specified endpoints -- `workspace`: Allows reading/writing workspace files -- `secrets`: Allows accessing injected secrets +Create alongside the .wasm file to grant capabilities: + +```json +{ + "http": { + "allowed_endpoints": [ + {"host": "api.example.com", "path_prefix": "/v1/"} + ] + }, + "workspace": true, + "secrets": { + "allowed": ["API_KEY"] + } +} +``` + +## Important Notes + +1. NEVER panic - always return Response with error field set +2. Secrets are NEVER exposed to WASM - use placeholders like `{API_KEY}` in URLs + and the host will inject the real value +3. HTTP requests are rate-limited and only allowed to endpoints in capabilities +4. Keep the tool focused on one thing - small, composable tools are better "# .to_string() @@ -714,11 +776,20 @@ impl SoftwareBuilder for LlmSoftwareBuilder { Description: {} +IMPORTANT: If this is a "tool" that the agent will use (e.g., "calendar tool", "email tool", +"API client tool"), you MUST use: +- software_type: "wasm_tool" +- language: "rust" + +Only use cli_binary/script/library for software meant for human end-users, not agent tools. + Respond with a JSON object containing: - name: A short identifier (snake_case) - description: What the software should do - software_type: One of "wasm_tool", "cli_binary", "library", "script", "web_service" + (PREFER "wasm_tool" for agent-usable tools) - language: One of "rust", "python", "typescript", "javascript", "go", "bash" + (PREFER "rust" for wasm_tool) - input_spec: Expected input format (optional) - output_spec: Expected output format (optional) - dependencies: List of external dependencies needed @@ -806,8 +877,10 @@ impl Tool for BuildSoftwareTool { } fn description(&self) -> &str { - "Build software from a description. Can create WASM tools, CLI applications, scripts, \ - and more. The builder will scaffold, implement, compile, and test the software iteratively." + "Build software from a description. IMPORTANT: For tools the agent will use, \ + ALWAYS build Rust WASM tools (type: wasm_tool, language: rust). Only use cli_binary, \ + script, or other types for software meant for human users. The builder scaffolds, \ + implements, compiles, and tests iteratively." } fn parameters_schema(&self) -> serde_json::Value { diff --git a/src/tools/builtin/file.rs b/src/tools/builtin/file.rs index 84aa6e89..416cafd1 100644 --- a/src/tools/builtin/file.rs +++ b/src/tools/builtin/file.rs @@ -119,7 +119,8 @@ impl Tool for ReadFileTool { } fn description(&self) -> &str { - "Read the contents of a file. Returns the file content as text. \ + "Read a file from the LOCAL FILESYSTEM. NOT for workspace memory paths \ + (use memory_read for those). Returns file content as text. \ For large files, you can specify offset and limit to read a portion." } @@ -239,8 +240,9 @@ impl Tool for WriteFileTool { } fn description(&self) -> &str { - "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. \ - Parent directories are created automatically. Use apply_patch for targeted edits to existing files." + "Write content to a file on the LOCAL FILESYSTEM. NOT for workspace memory \ + (use memory_write for that). Creates the file if it doesn't exist, overwrites if it does. \ + Parent directories are created automatically. Use apply_patch for targeted edits." } fn parameters_schema(&self) -> serde_json::Value { @@ -342,8 +344,8 @@ impl Tool for ListDirTool { } fn description(&self) -> &str { - "List contents of a directory. Shows files and subdirectories with their sizes. \ - Use for exploring project structure." + "List contents of a directory on the LOCAL FILESYSTEM. NOT for workspace memory \ + (use memory_tree for that). Shows files and subdirectories with their sizes." } fn parameters_schema(&self) -> serde_json::Value { diff --git a/src/tools/builtin/memory.rs b/src/tools/builtin/memory.rs index 1ab69f79..d50b4ee4 100644 --- a/src/tools/builtin/memory.rs +++ b/src/tools/builtin/memory.rs @@ -266,8 +266,10 @@ impl Tool for MemoryReadTool { } fn description(&self) -> &str { - "Read a file from the workspace. Use this to read identity files, \ - heartbeat checklist, memory, daily logs, or any custom file." + "Read a file from the workspace memory (database-backed storage). \ + Use this to read files shown by memory_tree. NOT for local filesystem files \ + (use read_file for those). Works with identity files, heartbeat checklist, \ + memory, daily logs, or any custom workspace path." } fn parameters_schema(&self) -> serde_json::Value { @@ -381,8 +383,9 @@ impl Tool for MemoryTreeTool { } fn description(&self) -> &str { - "View the workspace structure as a tree. Use this to explore \ - the workspace hierarchy and discover available files and directories." + "View the workspace memory structure as a tree (database-backed storage). \ + Use memory_read to read files shown here, NOT read_file. \ + The workspace is separate from the local filesystem." } fn parameters_schema(&self) -> serde_json::Value {