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 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-05 00:37:19 -08:00
co-authored by Claude Opus 4.5
parent 9edc666ee3
commit aec42aceda
5 changed files with 191 additions and 85 deletions
+35 -7
View File
@@ -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
)))
}
}
+1 -1
View File
@@ -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",
+141 -68
View File
@@ -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<String>;
// HTTP (if capability granted)
host::http_request(method: &str, url: &str, headers_json: &str, body: Option<Vec<u8>>)
-> Result<HttpResponse, String>;
// HttpResponse has: status: u16, headers_json: String, body: Vec<u8>
// Tool invocation (if capability granted)
host::tool_invoke(alias: &str, params_json: &str) -> Result<String, String>;
// 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 {
+7 -5
View File
@@ -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 {
+7 -4
View File
@@ -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 {