Skip to main content

Crate wasm_agent

Crate wasm_agent 

Source
Expand description

§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

FeatureDefaultWhat it addsExtra dependencies
(none)yestext ReAct loop, native tool calling, tool registry, token-limited history, owned Agentserde, serde_json, thiserror
schemarsnoToolRegistry::register_typed: schema from a Rust type, serde-parsed inputschemars
jsnoJavaScript Agent class with JS-function toolswasm-bindgen, js-sys

§Examples

ExampleShowsRun
quickstarttext ReAct with a system prompt and error recoverycargo run --example quickstart
native_toolsOpenAI and Anthropic tool-call bodies in and tool results out, including a misspelled toolcargo run --example native_tools
typed_toolsa tool taking a Rust struct; bad arguments go back to the modelcargo run --example typed_tools --features schemars
node/smoke.mjsthe JavaScript API from Nodesee the JavaScript section

§How it works

FileWhat it holds
src/loop_runner.rsparse_react_step, LoopRunner, Agent, NativeStep, system_prompt
src/native.rsprovider response parsing and tool-result formats
src/tools.rsToolRegistry (sorted, so prompts are stable), ToolSpec, dispatch_call, register_typed
src/history.rsConversationHistory with token-limit eviction that keeps tool results with their calls
src/types.rsReActStep, Message, Role, AgentConfig, ToolResult
src/js.rsJavaScript 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:

Task0.20.1.1
parse Action: search(...)177 ns71 ns
parse Thought: ... + Action: ...251 ns40 ns, but returns the Thought and never runs the tool
parse an OpenAI tool-call body1.6 µsnot supported
200 think/act steps with a 2,000-token history69 µs37 µ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.

Re-exports§

pub use error::AgentError;
pub use loop_runner::parse_react_step;
pub use loop_runner::system_prompt;
pub use loop_runner::Agent;
pub use loop_runner::LoopRunner;
pub use loop_runner::NativeStep;
pub use native::ToolCall;
pub use native::ToolOutput;
pub use tools::ToolRegistry;
pub use tools::ToolSpec;
pub use types::AgentConfig;
pub use types::Message;
pub use types::ReActStep;
pub use types::Role;

Modules§

error
The error type.
history
Conversation history management within WASM memory constraints.
jsjs
JavaScript bindings (feature js): an Agent class whose tools are JavaScript functions.
loop_runner
ReAct loop runner: Thought -> Action -> Observation -> FinalAnswer.
native
Native tool calling: the JSON formats OpenAI, Anthropic and Ollama use.
tools
Tool registry and dispatch for WASM-safe function signatures.
types
Core types for the ReAct agent loop.