Skip to content

About

ReAct agent loop (Thought, Action, Observation) in synchronous Rust with no async runtime or I/O, so it builds for wasm32 and runs in browsers, edge workers and plugins.

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

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.

crates.io docs.rs License: MIT

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

cargo add wasm-agent
# typed tools (schema from a Rust type) and JavaScript bindings:
cargo add wasm-agent --features schemars,js

In ten lines

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

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:

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)

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
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, 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

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.

About

ReAct agent loop (Thought, Action, Observation) in synchronous Rust with no async runtime or I/O, so it builds for wasm32 and runs in browsers, edge workers and plugins.

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages