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.
cargo add wasm-agent
# typed tools (schema from a Rust type) and JavaScript bindings:
cargo add wasm-agent --features schemars,jsuse 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(())
}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.
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!({"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.
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 pkgconst { 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).
- 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.
- 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.
| 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 |
| 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 |
| 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.
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.
- 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.
MIT, see LICENSE.