# wasm-agent
[](https://crates.io/crates/wasm-agent)
[](LICENSE)
A ReAct (Thought, Action, Observation, Final Answer) agent loop in plain synchronous Rust, with no async runtime and no I/O, so it builds for `wasm32-unknown-unknown` and runs in browsers, edge workers and plugins.
Most agent frameworks assume Tokio, threads and an HTTP client. This crate keeps only the part that is pure logic: parse what the model said, dispatch the tool it asked for, record the observation, and keep the conversation within a token budget. You make the LLM call however your platform allows (`fetch` in a Worker, a host function in a plugin) and hand the response text to the runner.
## Features
- **`parse_react_step`**: parses `Thought: ...`, `Action: tool(input)`, `Action: tool: input` and `Final Answer: ...` into a `ReActStep`.
- **`ToolRegistry`**: register synchronous tools (`Box<dyn Fn(&str) -> ToolResult>`, no `Send` needed) with a `ToolSpec` (name, description, JSON schema), dispatch by name, and generate a tools section for the system prompt with `tools_prompt()`.
- **`LoopRunner`**: `step(llm_response)` parses one response, runs the tool on an `Action`, and appends both the response and the tool output to history. `run_scripted` replays a list of responses for deterministic tests.
- **`ConversationHistory`**: token-limited history that evicts the oldest non-system messages when full.
- **`AgentConfig`**: max iterations, context token limit, timeout and model name.
- Dependencies are only `serde`, `serde_json` and `thiserror`.
## Quick start
```bash
cargo add wasm-agent
```
```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,
}),
)?;
println!("{}", tools.tools_prompt()); // put this in your system prompt
let config = AgentConfig::default();
let mut runner = LoopRunner::new(&config, &tools);
// In a real agent each string comes from your LLM call.
runner.step("Thought: I should look up the capital.")?;
runner.step("Action: capital(France)")?; // runs the tool, records "Paris"
match runner.step("Final Answer: Paris")? {
ReActStep::FinalAnswer(answer) => println!("answer: {answer}"),
other => println!("not done: {other:?}"),
}
println!("{} messages in history", runner.history().len());
Ok(())
}
```
Build for WebAssembly:
```bash
rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown --release
```
## How it works
| `src/loop_runner.rs` | `parse_react_step`, `LoopRunner` |
| `src/tools.rs` | `ToolRegistry`, `ToolSpec`, `ToolFn` |
| `src/history.rs` | `ConversationHistory` with token-limit eviction |
| `src/types.rs` | `ReActStep`, `Message`, `Role`, `AgentConfig`, `ToolResult` |
| `src/error.rs` | `AgentError` |
The runner never calls a model itself. That keeps it deterministic and testable, and lets the same code run natively or in WASM.
## Status and limitations
Version 0.1.
- No LLM client and no JS bindings are included; you supply the model call (for example via `wasm-bindgen` and `fetch`).
- Token counts are estimates, not a real tokenizer.
- `AgentConfig::timeout_ms` is stored but not enforced by the runner.
- The parser expects one step per response and matches the prefixes at the start of the text.
```bash
cargo test
cargo bench
```
## License
MIT, see [LICENSE](LICENSE).
---
Part of a set of Rust crates for LLM agents, see [rust-crates](https://gitlab.com/mattbusel/rust-crates).