Skip to main content

wasm_agent/
js.rs

1// SPDX-License-Identifier: MIT
2//! JavaScript bindings (feature `js`): an `Agent` class whose tools are
3//! JavaScript functions.
4//!
5//! ```js
6//! import init, { Agent } from "./wasm_agent.js";
7//! await init();
8//! const agent = new Agent(10, 8192, true);            // max steps, context tokens, recover from errors
9//! agent.registerTool("add", "Adds two numbers",
10//!   '{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}}}',
11//!   (args) => String(JSON.parse(args).a + JSON.parse(args).b));
12//! const body = { model: "gpt-4o-mini", tools: agent.openaiTools(), messages: [...] };
13//! const step = agent.stepResponse(await (await fetch(url, ...)).text());
14//! // step = { kind: "tool_calls", results: [...] } or { kind: "final", text }
15//! ```
16//!
17//! A tool function receives its input as a string and returns a string (other
18//! values are JSON-encoded). If it throws, the model sees `Error: <message>`.
19
20use wasm_bindgen::prelude::*;
21
22use crate::loop_runner::NativeStep;
23use crate::types::{AgentConfig, ReActStep, ToolResult};
24use crate::ToolSpec;
25
26fn js_err(e: crate::AgentError) -> JsError {
27    JsError::new(&e.to_string())
28}
29
30fn to_js(v: &serde_json::Value) -> Result<JsValue, JsError> {
31    js_sys::JSON::parse(&v.to_string()).map_err(|_| JsError::new("could not convert value to JavaScript"))
32}
33
34/// A ReAct / tool-calling agent for JavaScript.
35#[wasm_bindgen(js_name = Agent)]
36pub struct JsAgent {
37    inner: crate::Agent,
38}
39
40#[wasm_bindgen(js_class = Agent)]
41impl JsAgent {
42    /// `new Agent(maxIterations = 10, contextTokens = 8192, recoverFromErrors = false)`.
43    #[wasm_bindgen(constructor)]
44    pub fn new(max_iterations: Option<u32>, context_tokens: Option<usize>, recover_from_errors: Option<bool>) -> JsAgent {
45        let d = AgentConfig::default();
46        let config = AgentConfig {
47            max_iterations: max_iterations.unwrap_or(d.max_iterations),
48            context_token_limit: context_tokens.unwrap_or(d.context_token_limit),
49            recover_from_errors: recover_from_errors.unwrap_or(false),
50            ..d
51        };
52        JsAgent { inner: crate::Agent::new(config) }
53    }
54
55    /// Register a tool backed by a JavaScript function `(input: string) => string`.
56    #[wasm_bindgen(js_name = registerTool)]
57    pub fn register_tool(&mut self, name: &str, description: &str, schema: &str, f: js_sys::Function) -> Result<(), JsError> {
58        let tool = name.to_string();
59        self.inner
60            .tools_mut()
61            .register(
62                ToolSpec::new(name, description, schema),
63                Box::new(move |input: &str| match f.call1(&JsValue::NULL, &JsValue::from_str(input)) {
64                    Ok(v) => {
65                        let output = v.as_string().unwrap_or_else(|| {
66                            js_sys::JSON::stringify(&v).ok().and_then(|s| s.as_string()).unwrap_or_default()
67                        });
68                        ToolResult { tool_name: tool.clone(), output, success: true }
69                    }
70                    Err(e) => {
71                        let msg = js_sys::Reflect::get(&e, &JsValue::from_str("message"))
72                            .ok()
73                            .and_then(|m| m.as_string())
74                            .or_else(|| e.as_string())
75                            .unwrap_or_else(|| "tool threw an exception".into());
76                        ToolResult { tool_name: tool.clone(), output: msg, success: false }
77                    }
78                }),
79            )
80            .map_err(js_err)
81    }
82
83    /// Add the standard ReAct system prompt (with the tool list).
84    #[wasm_bindgen(js_name = addSystemPrompt)]
85    pub fn add_system_prompt(&mut self) {
86        self.inner.add_system_prompt();
87    }
88
89    /// Add the user's question.
90    pub fn ask(&mut self, question: &str) {
91        self.inner.ask(question);
92    }
93
94    /// Process one text reply. Returns `{kind, content, tool?, input?}` where
95    /// kind is `"thought"`, `"action"`, `"observation"` or `"final"`.
96    pub fn step(&mut self, reply: &str) -> Result<JsValue, JsError> {
97        let step = self.inner.step(reply).map_err(js_err)?;
98        let v = match step {
99            ReActStep::Thought(t) => serde_json::json!({"kind": "thought", "content": t}),
100            ReActStep::Action { tool, input } => serde_json::json!({"kind": "action", "content": input, "tool": tool, "input": input}),
101            ReActStep::Observation(o) => serde_json::json!({"kind": "observation", "content": o}),
102            ReActStep::FinalAnswer(a) => serde_json::json!({"kind": "final", "content": a}),
103        };
104        to_js(&v)
105    }
106
107    /// Process one raw response body from OpenAI, Anthropic or Ollama.
108    /// Returns `{kind: "tool_calls", results: [{id, name, success, output}]}`
109    /// or `{kind: "final", text}`.
110    #[wasm_bindgen(js_name = stepResponse)]
111    pub fn step_response(&mut self, body: &str) -> Result<JsValue, JsError> {
112        let v = match self.inner.step_response(body).map_err(js_err)? {
113            NativeStep::ToolCalls(results) => serde_json::json!({"kind": "tool_calls", "results": results}),
114            NativeStep::Final(text) => serde_json::json!({"kind": "final", "text": text}),
115        };
116        to_js(&v)
117    }
118
119    /// The `tools` array for OpenAI-style APIs.
120    #[wasm_bindgen(js_name = openaiTools)]
121    pub fn openai_tools(&self) -> Result<JsValue, JsError> {
122        to_js(&serde_json::Value::Array(self.inner.tools().openai_tools().map_err(js_err)?))
123    }
124
125    /// The `tools` array for Anthropic.
126    #[wasm_bindgen(js_name = anthropicTools)]
127    pub fn anthropic_tools(&self) -> Result<JsValue, JsError> {
128        to_js(&serde_json::Value::Array(self.inner.tools().anthropic_tools().map_err(js_err)?))
129    }
130
131    /// The conversation as one prompt string (text ReAct).
132    pub fn transcript(&self) -> String {
133        self.inner.transcript()
134    }
135
136    /// The conversation as `[{role, content}]` chat messages (text ReAct).
137    #[wasm_bindgen(js_name = chatMessages)]
138    pub fn chat_messages(&self) -> Result<JsValue, JsError> {
139        to_js(&serde_json::Value::Array(self.inner.history().to_chat_messages()))
140    }
141
142    /// Number of replies processed.
143    #[wasm_bindgen(getter)]
144    pub fn steps(&self) -> usize {
145        self.inner.iteration_count()
146    }
147}