# 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.
[](https://crates.io/crates/wasm-agent)
[](https://docs.rs/wasm-agent)
[](https://gitlab.com/mattbusel/wasm-agent/-/blob/main/LICENSE)
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
```bash
cargo add wasm-agent
# typed tools (schema from a Rust type) and JavaScript bindings:
cargo add wasm-agent --features schemars,js
```
## In ten lines
```rust
use wasm_agent::{Agent, AgentConfig, ReActStep, ToolSpec};
use wasm_agent::types::ToolResult;
fn main() -> Result<(), wasm_agent::AgentError> {
let mut agent = Agent::new(AgentConfig::default());
agent.tools_mut().register(ToolSpec::new("capital", "Capital of a country", r#"{"type":"string"}"#),
Box::new(|c: &str| ToolResult { tool_name: "capital".into(), output: if c == "France" { "Paris".into() } else { "unknown".into() }, success: true }))?;
agent.step("Thought: I should look it up.\nAction: capital(France)")?; // runs the tool
assert!(agent.transcript().contains("Observation: Paris"));
assert_eq!(agent.step("Final Answer: Paris")?, ReActStep::FinalAnswer("Paris".into()));
Ok(())
}
```
## Quickstart
```rust
use wasm_agent::{AgentConfig, LoopRunner, ReActStep, ToolRegistry, ToolSpec};
use wasm_agent::types::ToolResult;
fn main() -> Result<(), wasm_agent::AgentError> {
let mut tools = ToolRegistry::new();
tools.register(
ToolSpec::new("capital", "Returns the capital of a country", r#"{"type":"string"}"#),
Box::new(|country: &str| ToolResult {
tool_name: "capital".into(),
output: if country == "France" { "Paris".into() } else { "unknown".into() },
success: true,
}),
)?;
// Let the model fix its own mistakes (unknown tool, missing labels)
// instead of ending the loop with an error.
let config = AgentConfig { recover_from_errors: true, ..Default::default() };
let mut runner = LoopRunner::new(&config, &tools).with_system_prompt();
runner.ask("What is the capital of France?");
// In a real agent each string comes from your LLM call; send
// runner.transcript() (or runner.history().to_chat_messages()) each time.
runner.step("Thought: I should look it up.\nAction: capital(France)")?;
match runner.step("Final Answer: Paris")? {
ReActStep::FinalAnswer(answer) => println!("answer: {answer}"),
other => println!("not done: {other:?}"),
}
print!("{}", runner.transcript());
Ok(())
}
```
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:
```rust,ignore
let body = json!({"model": "gpt-4o-mini", "tools": agent.tools().openai_tools()?, "messages": messages});
// ... POST it, then:
match agent.step_response(&response_text)? {
NativeStep::ToolCalls(results) => messages.extend(wasm_agent::native::tool_results_openai(&results)),
NativeStep::Final(answer) => return Ok(answer),
}
```
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`)
```bash
cargo build --release --target wasm32-unknown-unknown --features js
wasm-bindgen --target nodejs --out-dir pkg target/wasm32-unknown-unknown/release/wasm_agent.wasm
node examples/node/smoke.mjs pkg
```
```js
const { Agent } = require("./pkg/wasm_agent.js");
const agent = new Agent(10, 8192, true); // max steps, context tokens, recover from errors
agent.registerTool("add", "Adds numbers", '{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}}}',
(args) => { const { a, b } = JSON.parse(args); return String(a + b); });
const step = agent.stepResponse(await (await fetch(url, { method: "POST", body: JSON.stringify({ tools: agent.openaiTools(), messages }) })).text());
// { 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](https://crates.io/crates/rig-core), [genai](https://crates.io/crates/genai), LangChain-style crates** make the HTTP calls and need Tokio; they are the better choice on a server. `wasm-agent` is 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:` and `Action:` in one reply, wraps labels in `**bold**`, invents an `Observation:`, 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>()` (feature `schemars`) 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
| (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](https://crates.io/crates/schemars) |
| `js` | no | JavaScript `Agent` class with JS-function tools | wasm-bindgen, js-sys |
## Examples
| `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
| `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:
| 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](https://gitlab.com/mattbusel/wasm-agent/-/blob/main/CONTRIBUTING.md).
## License
MIT, see [LICENSE](https://gitlab.com/mattbusel/wasm-agent/-/blob/main/LICENSE).