wasm-agent 0.1.1

ReAct agent loop for WASM/edge environments: Thought-Action-Observation with tool dispatch
Documentation

wasm-agent

crates.io License: MIT

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

cargo add wasm-agent
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:

rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown --release

How it works

File What it holds
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.
cargo test
cargo bench

License

MIT, see LICENSE.


Part of a set of Rust crates for LLM agents, see rust-crates.