wasm-agent
An agent loop for LLMs with no I/O and no async runtime: it reads what the model said (text ReAct or native tool calls from OpenAI, Anthropic and Ollama), runs the tool it asked for, and keeps the conversation within a token budget. It builds for wasm32-unknown-unknown, so it runs in browsers, edge workers and plugins, and has JavaScript bindings.
Most agent frameworks assume Tokio, threads and an HTTP client. This crate keeps only the logic: you make the model call however your platform allows (fetch in a Worker, a host function in a plugin) and hand the reply to the agent.
Install
# typed tools (schema from a Rust type) and JavaScript bindings:
In ten lines
use ;
use ToolResult;
Quickstart
use ;
use ToolResult;
The same program is examples/quickstart.rs: cargo run --example quickstart.
Native tool calling
Current models call tools through the API. wasm-agent produces the tools array, reads the calls from the raw response body, runs them, and formats the results to send back:
let body = json!;
// ... POST it, then:
match agent.step_response?
Supported response formats: OpenAI Chat Completions, OpenAI Responses, Anthropic Messages, Ollama /api/chat (and every OpenAI-compatible API: Mistral, Groq, DeepSeek, OpenRouter). Tools declared with a non-object schema (such as {"type":"string"}) are wrapped as {"input": ...} for the API and unwrapped before your function runs. See examples/native_tools.rs.
JavaScript (feature js)
const = require;
const agent = ; // max steps, context tokens, recover from errors
agent.;
const step = agent.;
// { kind: "tool_calls", results: [{ id, name, success, output }] } or { kind: "final", text }
A tool that throws becomes an Error: <message> result the model can read. The module is 208 KB (release, after wasm-bindgen, before wasm-opt).
Why this and not a framework
- rig, genai, LangChain-style crates make the HTTP calls and need Tokio; they are the better choice on a server.
wasm-agentis for places where you cannot or do not want that: a browser, a Worker, a WASM plugin, a test harness that replays recorded replies. - Writing the loop yourself is short until the model writes
Thought:andAction:in one reply, wraps labels in**bold**, invents anObservation:, calls a tool that does not exist, or the history overflows and drops the message a tool result belongs to. Each of those is handled and tested here.
Already using another crate or API?
- OpenAI / Anthropic / Ollama APIs:
openai_tools(),anthropic_tools(),step_response(body),tool_results_openai/anthropic/responses(). - async-openai / reqwest / fetch: pass the response text; nothing here depends on a client.
- schemars + serde:
register_typed::<T>()(featureschemars) derives the schema from your type and parses arguments into it. - llm-wasm (same author): cost, retry, guards and streaming for the same WASM targets.
Features
| Feature | Default | What it adds | Extra dependencies |
|---|---|---|---|
| (none) | yes | text ReAct loop, native tool calling, tool registry, token-limited history, owned Agent |
serde, serde_json, thiserror |
schemars |
no | ToolRegistry::register_typed: schema from a Rust type, serde-parsed input |
schemars |
js |
no | JavaScript Agent class with JS-function tools |
wasm-bindgen, js-sys |
Examples
| Example | Shows | Run |
|---|---|---|
quickstart |
text ReAct with a system prompt and error recovery | cargo run --example quickstart |
native_tools |
OpenAI and Anthropic tool-call bodies in and tool results out, including a misspelled tool | cargo run --example native_tools |
typed_tools |
a tool taking a Rust struct; bad arguments go back to the model | cargo run --example typed_tools --features schemars |
node/smoke.mjs |
the JavaScript API from Node | see the JavaScript section |
How it works
| File | What it holds |
|---|---|
src/loop_runner.rs |
parse_react_step, LoopRunner, Agent, NativeStep, system_prompt |
src/native.rs |
provider response parsing and tool-result formats |
src/tools.rs |
ToolRegistry (sorted, so prompts are stable), ToolSpec, dispatch_call, register_typed |
src/history.rs |
ConversationHistory with token-limit eviction that keeps tool results with their calls |
src/types.rs |
ReActStep, Message, Role, AgentConfig, ToolResult |
src/js.rs |
JavaScript bindings (feature js) |
The parser looks for labels at the start of any line (case-insensitive, markdown bold allowed), picks the most decisive one (Final Answer over Action over Thought), and ignores anything from an invented Observation: on.
Performance
benches/vs_previous.rs against 0.1.1 (no other crate runs this loop without I/O). Intel i7-13700KF, Windows 11, release build, other work running, so rough:
| Task | 0.2 | 0.1.1 |
|---|---|---|
parse Action: search(...) |
177 ns | 71 ns |
parse Thought: ... + Action: ... |
251 ns | 40 ns, but returns the Thought and never runs the tool |
| parse an OpenAI tool-call body | 1.6 µs | not supported |
| 200 think/act steps with a 2,000-token history | 69 µs | 37 µs |
0.2 is slower because it scans every line for every label and keeps tool results paired with their calls; it is still under a microsecond per reply, which is nothing next to a model call.
Limitations
- Token counts are estimates (one token per four bytes, rounded up), not a real tokenizer.
- Text ReAct handles one action per reply; native tool calling handles any number.
- Tools are synchronous functions; an async tool must be driven by your own code between steps.
Run the tests with cargo test --all-features. Contributions are welcome, see CONTRIBUTING.md.
License
MIT, see LICENSE.