simple-agents-wasm 0.2.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/index.d.ts +155 -0
- package/index.js +734 -0
- package/package.json +28 -0
- package/pkg/simple_agents_wasm.d.ts +62 -0
- package/pkg/simple_agents_wasm.js +749 -0
- package/pkg/simple_agents_wasm_bg.wasm +0 -0
- package/pkg/simple_agents_wasm_bg.wasm.d.ts +21 -0
- package/rust/Cargo.toml +19 -0
- package/rust/src/lib.rs +1034 -0
package/rust/src/lib.rs
ADDED
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
use js_sys::{Array, Function, Object, Promise, Reflect};
|
|
2
|
+
use serde::{Deserialize, Serialize};
|
|
3
|
+
use serde_json::{json, Map as JsonMap, Value as JsonValue};
|
|
4
|
+
use std::collections::HashMap;
|
|
5
|
+
use wasm_bindgen::prelude::*;
|
|
6
|
+
use wasm_bindgen::JsCast;
|
|
7
|
+
use wasm_bindgen_futures::JsFuture;
|
|
8
|
+
|
|
9
|
+
#[derive(Deserialize)]
|
|
10
|
+
#[serde(rename_all = "camelCase")]
|
|
11
|
+
struct ClientConfig {
|
|
12
|
+
api_key: String,
|
|
13
|
+
base_url: Option<String>,
|
|
14
|
+
headers: Option<HashMap<String, String>>,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#[derive(Deserialize, Serialize, Clone)]
|
|
18
|
+
#[serde(rename_all = "camelCase")]
|
|
19
|
+
struct MessageInput {
|
|
20
|
+
role: String,
|
|
21
|
+
content: String,
|
|
22
|
+
name: Option<String>,
|
|
23
|
+
tool_call_id: Option<String>,
|
|
24
|
+
tool_calls: Option<Vec<JsToolCall>>,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[derive(Deserialize, Serialize, Clone)]
|
|
28
|
+
#[serde(rename_all = "camelCase")]
|
|
29
|
+
struct JsToolCall {
|
|
30
|
+
id: String,
|
|
31
|
+
tool_type: Option<String>,
|
|
32
|
+
function: JsToolCallFunction,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#[derive(Deserialize, Serialize, Clone)]
|
|
36
|
+
struct JsToolCallFunction {
|
|
37
|
+
name: String,
|
|
38
|
+
arguments: String,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#[derive(Deserialize, Default, Clone)]
|
|
42
|
+
#[serde(rename_all = "camelCase")]
|
|
43
|
+
struct CompleteOptions {
|
|
44
|
+
max_tokens: Option<u32>,
|
|
45
|
+
temperature: Option<f64>,
|
|
46
|
+
top_p: Option<f64>,
|
|
47
|
+
mode: Option<String>,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#[derive(Deserialize)]
|
|
51
|
+
struct WorkflowDoc {
|
|
52
|
+
model: Option<String>,
|
|
53
|
+
steps: Vec<WorkflowStep>,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#[derive(Deserialize, Clone)]
|
|
57
|
+
struct WorkflowStep {
|
|
58
|
+
id: String,
|
|
59
|
+
#[serde(rename = "type")]
|
|
60
|
+
step_type: String,
|
|
61
|
+
key: Option<String>,
|
|
62
|
+
value: Option<JsonValue>,
|
|
63
|
+
prompt: Option<String>,
|
|
64
|
+
condition: Option<WorkflowCondition>,
|
|
65
|
+
then: Option<String>,
|
|
66
|
+
r#else: Option<String>,
|
|
67
|
+
text: Option<String>,
|
|
68
|
+
function: Option<String>,
|
|
69
|
+
args: Option<JsonValue>,
|
|
70
|
+
next: Option<String>,
|
|
71
|
+
model: Option<String>,
|
|
72
|
+
temperature: Option<f64>,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#[derive(Deserialize, Clone)]
|
|
76
|
+
struct WorkflowCondition {
|
|
77
|
+
left: JsonValue,
|
|
78
|
+
operator: String,
|
|
79
|
+
right: JsonValue,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[derive(Serialize)]
|
|
83
|
+
#[serde(rename_all = "camelCase")]
|
|
84
|
+
struct CompletionUsage {
|
|
85
|
+
prompt_tokens: u32,
|
|
86
|
+
completion_tokens: u32,
|
|
87
|
+
total_tokens: u32,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[derive(Serialize)]
|
|
91
|
+
#[serde(rename_all = "camelCase")]
|
|
92
|
+
struct ToolCallFunctionOut {
|
|
93
|
+
name: String,
|
|
94
|
+
arguments: String,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#[derive(Serialize)]
|
|
98
|
+
#[serde(rename_all = "camelCase")]
|
|
99
|
+
struct ToolCallOut {
|
|
100
|
+
id: String,
|
|
101
|
+
tool_type: String,
|
|
102
|
+
function: ToolCallFunctionOut,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#[derive(Serialize)]
|
|
106
|
+
#[serde(rename_all = "camelCase")]
|
|
107
|
+
struct CompletionResult {
|
|
108
|
+
id: String,
|
|
109
|
+
model: String,
|
|
110
|
+
role: String,
|
|
111
|
+
content: Option<String>,
|
|
112
|
+
tool_calls: Option<Vec<ToolCallOut>>,
|
|
113
|
+
finish_reason: Option<String>,
|
|
114
|
+
usage: CompletionUsage,
|
|
115
|
+
usage_available: bool,
|
|
116
|
+
latency_ms: u32,
|
|
117
|
+
raw: Option<String>,
|
|
118
|
+
healed: Option<JsonValue>,
|
|
119
|
+
coerced: Option<JsonValue>,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#[derive(Serialize)]
|
|
123
|
+
#[serde(rename_all = "camelCase")]
|
|
124
|
+
struct WorkflowRunEvent {
|
|
125
|
+
step_id: String,
|
|
126
|
+
step_type: String,
|
|
127
|
+
status: String,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#[derive(Serialize)]
|
|
131
|
+
#[serde(rename_all = "camelCase")]
|
|
132
|
+
struct WorkflowRunResult {
|
|
133
|
+
status: String,
|
|
134
|
+
context: JsonValue,
|
|
135
|
+
output: Option<JsonValue>,
|
|
136
|
+
events: Vec<WorkflowRunEvent>,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#[derive(Deserialize)]
|
|
140
|
+
#[serde(rename_all = "camelCase")]
|
|
141
|
+
struct WorkflowRunOptions {
|
|
142
|
+
#[allow(dead_code)]
|
|
143
|
+
telemetry: Option<JsonValue>,
|
|
144
|
+
#[allow(dead_code)]
|
|
145
|
+
trace: Option<JsonValue>,
|
|
146
|
+
#[serde(skip)]
|
|
147
|
+
functions_js: Option<JsValue>,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fn js_error(message: impl Into<String>) -> JsValue {
|
|
151
|
+
js_sys::Error::new(&format!(
|
|
152
|
+
"simple-agents-wasm runtime error: {}",
|
|
153
|
+
message.into()
|
|
154
|
+
))
|
|
155
|
+
.into()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
fn config_error(message: impl Into<String>) -> JsValue {
|
|
159
|
+
js_sys::Error::new(&format!(
|
|
160
|
+
"simple-agents-wasm config error: {}",
|
|
161
|
+
message.into()
|
|
162
|
+
))
|
|
163
|
+
.into()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fn now_millis() -> f64 {
|
|
167
|
+
let global = js_sys::global();
|
|
168
|
+
let performance = Reflect::get(&global, &JsValue::from_str("performance")).ok();
|
|
169
|
+
if let Some(perf) = performance {
|
|
170
|
+
if let Ok(now) = Reflect::get(&perf, &JsValue::from_str("now")) {
|
|
171
|
+
if let Some(now_fn) = now.dyn_ref::<Function>() {
|
|
172
|
+
if let Ok(v) = now_fn.call0(&perf) {
|
|
173
|
+
return v.as_f64().unwrap_or(0.0);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
0.0
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
fn to_messages(prompt_or_messages: JsValue) -> Result<Vec<MessageInput>, JsValue> {
|
|
182
|
+
if let Some(prompt) = prompt_or_messages.as_string() {
|
|
183
|
+
let trimmed = prompt.trim();
|
|
184
|
+
if trimmed.is_empty() {
|
|
185
|
+
return Err(config_error("prompt cannot be empty"));
|
|
186
|
+
}
|
|
187
|
+
return Ok(vec![MessageInput {
|
|
188
|
+
role: "user".to_string(),
|
|
189
|
+
content: trimmed.to_string(),
|
|
190
|
+
name: None,
|
|
191
|
+
tool_call_id: None,
|
|
192
|
+
tool_calls: None,
|
|
193
|
+
}]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let messages: Vec<MessageInput> = serde_wasm_bindgen::from_value(prompt_or_messages)
|
|
197
|
+
.map_err(|_| config_error("messages must be a non-empty array"))?;
|
|
198
|
+
if messages.is_empty() {
|
|
199
|
+
return Err(config_error("messages must be a non-empty array"));
|
|
200
|
+
}
|
|
201
|
+
Ok(messages)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
fn normalize_base_url(base_url: &str) -> String {
|
|
205
|
+
base_url.trim_end_matches('/').to_string()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
fn default_base_url(provider: &str) -> Option<&'static str> {
|
|
209
|
+
match provider {
|
|
210
|
+
"openai" => Some("https://api.openai.com/v1"),
|
|
211
|
+
"openrouter" => Some("https://openrouter.ai/api/v1"),
|
|
212
|
+
_ => None,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async fn call_method0(target: &JsValue, method: &str) -> Result<JsValue, JsValue> {
|
|
217
|
+
let method_value = Reflect::get(target, &JsValue::from_str(method))
|
|
218
|
+
.map_err(|_| js_error(format!("missing method: {method}")))?;
|
|
219
|
+
let method_fn = method_value
|
|
220
|
+
.dyn_into::<Function>()
|
|
221
|
+
.map_err(|_| js_error(format!("method is not callable: {method}")))?;
|
|
222
|
+
let out = method_fn
|
|
223
|
+
.call0(target)
|
|
224
|
+
.map_err(|_| js_error(format!("failed to call method: {method}")))?;
|
|
225
|
+
let promise = out
|
|
226
|
+
.dyn_into::<Promise>()
|
|
227
|
+
.map_err(|_| js_error(format!("method did not return Promise: {method}")))?;
|
|
228
|
+
JsFuture::from(promise)
|
|
229
|
+
.await
|
|
230
|
+
.map_err(|_| js_error(format!("await failed for method: {method}")))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async fn js_fetch(
|
|
234
|
+
url: &str,
|
|
235
|
+
body: &JsonValue,
|
|
236
|
+
headers: &HashMap<String, String>,
|
|
237
|
+
) -> Result<JsValue, JsValue> {
|
|
238
|
+
let global = js_sys::global();
|
|
239
|
+
let fetch_value = Reflect::get(&global, &JsValue::from_str("fetch"))
|
|
240
|
+
.map_err(|_| js_error("global fetch is unavailable"))?;
|
|
241
|
+
let fetch_fn = fetch_value
|
|
242
|
+
.dyn_into::<Function>()
|
|
243
|
+
.map_err(|_| js_error("global fetch is not callable"))?;
|
|
244
|
+
|
|
245
|
+
let options = Object::new();
|
|
246
|
+
Reflect::set(
|
|
247
|
+
&options,
|
|
248
|
+
&JsValue::from_str("method"),
|
|
249
|
+
&JsValue::from_str("POST"),
|
|
250
|
+
)
|
|
251
|
+
.map_err(|_| js_error("failed to set request method"))?;
|
|
252
|
+
|
|
253
|
+
let headers_obj = Object::new();
|
|
254
|
+
Reflect::set(
|
|
255
|
+
&headers_obj,
|
|
256
|
+
&JsValue::from_str("Content-Type"),
|
|
257
|
+
&JsValue::from_str("application/json"),
|
|
258
|
+
)
|
|
259
|
+
.map_err(|_| js_error("failed to set content-type header"))?;
|
|
260
|
+
for (key, value) in headers {
|
|
261
|
+
Reflect::set(
|
|
262
|
+
&headers_obj,
|
|
263
|
+
&JsValue::from_str(key),
|
|
264
|
+
&JsValue::from_str(value),
|
|
265
|
+
)
|
|
266
|
+
.map_err(|_| js_error("failed to set custom header"))?;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
Reflect::set(&options, &JsValue::from_str("headers"), &headers_obj)
|
|
270
|
+
.map_err(|_| js_error("failed to set request headers"))?;
|
|
271
|
+
let body_str =
|
|
272
|
+
serde_json::to_string(body).map_err(|_| js_error("failed to serialize request body"))?;
|
|
273
|
+
Reflect::set(
|
|
274
|
+
&options,
|
|
275
|
+
&JsValue::from_str("body"),
|
|
276
|
+
&JsValue::from_str(&body_str),
|
|
277
|
+
)
|
|
278
|
+
.map_err(|_| js_error("failed to set request body"))?;
|
|
279
|
+
|
|
280
|
+
let response_promise = fetch_fn
|
|
281
|
+
.call2(&global, &JsValue::from_str(url), &options)
|
|
282
|
+
.map_err(|_| js_error("fetch call failed"))?
|
|
283
|
+
.dyn_into::<Promise>()
|
|
284
|
+
.map_err(|_| js_error("fetch did not return Promise"))?;
|
|
285
|
+
JsFuture::from(response_promise)
|
|
286
|
+
.await
|
|
287
|
+
.map_err(|_| js_error("fetch await failed"))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
fn interpolate_string(input: &str, context: &JsonMap<String, JsonValue>) -> String {
|
|
291
|
+
let mut output = String::new();
|
|
292
|
+
let mut rest = input;
|
|
293
|
+
|
|
294
|
+
while let Some(start) = rest.find("{{") {
|
|
295
|
+
output.push_str(&rest[..start]);
|
|
296
|
+
let after_start = &rest[start + 2..];
|
|
297
|
+
if let Some(end) = after_start.find("}}") {
|
|
298
|
+
let key = after_start[..end].trim();
|
|
299
|
+
let replacement = context
|
|
300
|
+
.get(key)
|
|
301
|
+
.map(|value| match value {
|
|
302
|
+
JsonValue::String(s) => s.clone(),
|
|
303
|
+
_ => serde_json::to_string(value).unwrap_or_default(),
|
|
304
|
+
})
|
|
305
|
+
.unwrap_or_default();
|
|
306
|
+
output.push_str(&replacement);
|
|
307
|
+
rest = &after_start[end + 2..];
|
|
308
|
+
} else {
|
|
309
|
+
output.push_str(&rest[start..]);
|
|
310
|
+
rest = "";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
output.push_str(rest);
|
|
315
|
+
output
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
fn interpolate_json(value: &JsonValue, context: &JsonMap<String, JsonValue>) -> JsonValue {
|
|
319
|
+
match value {
|
|
320
|
+
JsonValue::String(s) => JsonValue::String(interpolate_string(s, context)),
|
|
321
|
+
JsonValue::Array(arr) => JsonValue::Array(
|
|
322
|
+
arr.iter()
|
|
323
|
+
.map(|item| interpolate_json(item, context))
|
|
324
|
+
.collect(),
|
|
325
|
+
),
|
|
326
|
+
JsonValue::Object(obj) => {
|
|
327
|
+
let mapped = obj
|
|
328
|
+
.iter()
|
|
329
|
+
.map(|(k, v)| (k.clone(), interpolate_json(v, context)))
|
|
330
|
+
.collect::<JsonMap<String, JsonValue>>();
|
|
331
|
+
JsonValue::Object(mapped)
|
|
332
|
+
}
|
|
333
|
+
_ => value.clone(),
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
fn evaluate_condition(condition: &WorkflowCondition, context: &JsonMap<String, JsonValue>) -> bool {
|
|
338
|
+
let left = interpolate_json(&condition.left, context);
|
|
339
|
+
let right = interpolate_json(&condition.right, context);
|
|
340
|
+
|
|
341
|
+
match condition.operator.as_str() {
|
|
342
|
+
"eq" => left == right,
|
|
343
|
+
"ne" => left != right,
|
|
344
|
+
"contains" => {
|
|
345
|
+
let l = match left {
|
|
346
|
+
JsonValue::String(s) => s,
|
|
347
|
+
_ => serde_json::to_string(&left).unwrap_or_default(),
|
|
348
|
+
};
|
|
349
|
+
let r = match right {
|
|
350
|
+
JsonValue::String(s) => s,
|
|
351
|
+
_ => serde_json::to_string(&right).unwrap_or_default(),
|
|
352
|
+
};
|
|
353
|
+
l.contains(&r)
|
|
354
|
+
}
|
|
355
|
+
_ => false,
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
fn parse_sse_blocks(text: &str) -> Vec<String> {
|
|
360
|
+
text.split("\n\n")
|
|
361
|
+
.map(str::trim)
|
|
362
|
+
.filter(|block| !block.is_empty())
|
|
363
|
+
.map(str::to_string)
|
|
364
|
+
.collect()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
fn parse_sse_data_line(block: &str) -> Option<String> {
|
|
368
|
+
let mut data_lines = Vec::new();
|
|
369
|
+
for line in block.lines() {
|
|
370
|
+
if let Some(rest) = line.strip_prefix("data:") {
|
|
371
|
+
data_lines.push(rest.trim_start().to_string());
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if data_lines.is_empty() {
|
|
375
|
+
None
|
|
376
|
+
} else {
|
|
377
|
+
Some(data_lines.join("\n"))
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
#[wasm_bindgen]
|
|
382
|
+
pub struct WasmClient {
|
|
383
|
+
provider: String,
|
|
384
|
+
base_url: String,
|
|
385
|
+
api_key: String,
|
|
386
|
+
headers: HashMap<String, String>,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#[wasm_bindgen]
|
|
390
|
+
impl WasmClient {
|
|
391
|
+
#[wasm_bindgen(constructor)]
|
|
392
|
+
pub fn new(provider: String, config: JsValue) -> Result<WasmClient, JsValue> {
|
|
393
|
+
if provider != "openai" && provider != "openrouter" {
|
|
394
|
+
return Err(config_error(
|
|
395
|
+
"provider must be 'openai' or 'openrouter' in wasm mode",
|
|
396
|
+
));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let parsed: ClientConfig = serde_wasm_bindgen::from_value(config)
|
|
400
|
+
.map_err(|_| config_error("invalid client config object"))?;
|
|
401
|
+
if parsed.api_key.trim().is_empty() {
|
|
402
|
+
return Err(config_error("config.apiKey is required"));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let base = parsed
|
|
406
|
+
.base_url
|
|
407
|
+
.or_else(|| default_base_url(&provider).map(str::to_string))
|
|
408
|
+
.ok_or_else(|| config_error("baseUrl is required"))?;
|
|
409
|
+
|
|
410
|
+
Ok(Self {
|
|
411
|
+
provider,
|
|
412
|
+
base_url: normalize_base_url(&base),
|
|
413
|
+
api_key: parsed.api_key,
|
|
414
|
+
headers: parsed.headers.unwrap_or_default(),
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#[wasm_bindgen(js_name = complete)]
|
|
419
|
+
pub async fn complete(
|
|
420
|
+
&self,
|
|
421
|
+
model: String,
|
|
422
|
+
prompt_or_messages: JsValue,
|
|
423
|
+
options: Option<JsValue>,
|
|
424
|
+
) -> Result<JsValue, JsValue> {
|
|
425
|
+
if model.trim().is_empty() {
|
|
426
|
+
return Err(config_error("model cannot be empty"));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
let opts = if let Some(value) = options {
|
|
430
|
+
serde_wasm_bindgen::from_value::<CompleteOptions>(value)
|
|
431
|
+
.map_err(|_| config_error("invalid options object"))?
|
|
432
|
+
} else {
|
|
433
|
+
CompleteOptions::default()
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
if let Some(mode) = opts.mode.as_deref() {
|
|
437
|
+
if mode == "healed_json" || mode == "schema" {
|
|
438
|
+
return Err(js_error(
|
|
439
|
+
"healed_json and schema modes are not supported in simple-agents-wasm yet",
|
|
440
|
+
));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let messages = to_messages(prompt_or_messages)?;
|
|
445
|
+
let messages_value = serde_json::to_value(messages)
|
|
446
|
+
.map_err(|_| js_error("failed to serialize request messages"))?;
|
|
447
|
+
let body = json!({
|
|
448
|
+
"model": model,
|
|
449
|
+
"messages": messages_value,
|
|
450
|
+
"max_tokens": opts.max_tokens,
|
|
451
|
+
"temperature": opts.temperature,
|
|
452
|
+
"top_p": opts.top_p,
|
|
453
|
+
"stream": false
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
let started = now_millis();
|
|
457
|
+
let mut headers = self.headers.clone();
|
|
458
|
+
headers.insert(
|
|
459
|
+
"Authorization".to_string(),
|
|
460
|
+
format!("Bearer {}", self.api_key),
|
|
461
|
+
);
|
|
462
|
+
if self.provider == "openrouter" {
|
|
463
|
+
headers
|
|
464
|
+
.entry("HTTP-Referer".to_string())
|
|
465
|
+
.or_insert_with(|| "https://simpleagents.dev".to_string());
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let response = js_fetch(
|
|
469
|
+
&format!("{}/chat/completions", self.base_url),
|
|
470
|
+
&body,
|
|
471
|
+
&headers,
|
|
472
|
+
)
|
|
473
|
+
.await?;
|
|
474
|
+
|
|
475
|
+
let ok = Reflect::get(&response, &JsValue::from_str("ok"))
|
|
476
|
+
.ok()
|
|
477
|
+
.and_then(|v| v.as_bool())
|
|
478
|
+
.unwrap_or(false);
|
|
479
|
+
if !ok {
|
|
480
|
+
let status = Reflect::get(&response, &JsValue::from_str("status"))
|
|
481
|
+
.ok()
|
|
482
|
+
.and_then(|v| v.as_f64())
|
|
483
|
+
.unwrap_or(0.0) as u16;
|
|
484
|
+
let text_js = call_method0(&response, "text").await?;
|
|
485
|
+
let text = text_js.as_string().unwrap_or_default();
|
|
486
|
+
return Err(js_error(format!(
|
|
487
|
+
"request failed ({status}): {}",
|
|
488
|
+
text.chars().take(500).collect::<String>()
|
|
489
|
+
)));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let json_js = call_method0(&response, "json").await?;
|
|
493
|
+
let payload: JsonValue = serde_wasm_bindgen::from_value(json_js)
|
|
494
|
+
.map_err(|_| js_error("failed to parse response JSON"))?;
|
|
495
|
+
|
|
496
|
+
let choice = payload
|
|
497
|
+
.get("choices")
|
|
498
|
+
.and_then(JsonValue::as_array)
|
|
499
|
+
.and_then(|arr| arr.first())
|
|
500
|
+
.cloned()
|
|
501
|
+
.unwrap_or(JsonValue::Null);
|
|
502
|
+
|
|
503
|
+
let content = choice
|
|
504
|
+
.get("message")
|
|
505
|
+
.and_then(|m| m.get("content"))
|
|
506
|
+
.and_then(JsonValue::as_str)
|
|
507
|
+
.map(str::to_string);
|
|
508
|
+
|
|
509
|
+
let tool_calls = choice
|
|
510
|
+
.get("message")
|
|
511
|
+
.and_then(|m| m.get("tool_calls"))
|
|
512
|
+
.and_then(JsonValue::as_array)
|
|
513
|
+
.map(|arr| {
|
|
514
|
+
arr.iter()
|
|
515
|
+
.map(|call| ToolCallOut {
|
|
516
|
+
id: call
|
|
517
|
+
.get("id")
|
|
518
|
+
.and_then(JsonValue::as_str)
|
|
519
|
+
.unwrap_or_default()
|
|
520
|
+
.to_string(),
|
|
521
|
+
tool_type: call
|
|
522
|
+
.get("type")
|
|
523
|
+
.and_then(JsonValue::as_str)
|
|
524
|
+
.unwrap_or("function")
|
|
525
|
+
.to_string(),
|
|
526
|
+
function: ToolCallFunctionOut {
|
|
527
|
+
name: call
|
|
528
|
+
.get("function")
|
|
529
|
+
.and_then(|f| f.get("name"))
|
|
530
|
+
.and_then(JsonValue::as_str)
|
|
531
|
+
.unwrap_or_default()
|
|
532
|
+
.to_string(),
|
|
533
|
+
arguments: call
|
|
534
|
+
.get("function")
|
|
535
|
+
.and_then(|f| f.get("arguments"))
|
|
536
|
+
.and_then(JsonValue::as_str)
|
|
537
|
+
.unwrap_or_default()
|
|
538
|
+
.to_string(),
|
|
539
|
+
},
|
|
540
|
+
})
|
|
541
|
+
.collect::<Vec<_>>()
|
|
542
|
+
})
|
|
543
|
+
.filter(|items| !items.is_empty());
|
|
544
|
+
|
|
545
|
+
let usage = payload.get("usage").cloned().unwrap_or(JsonValue::Null);
|
|
546
|
+
let usage_obj = CompletionUsage {
|
|
547
|
+
prompt_tokens: usage
|
|
548
|
+
.get("prompt_tokens")
|
|
549
|
+
.and_then(JsonValue::as_u64)
|
|
550
|
+
.unwrap_or(0) as u32,
|
|
551
|
+
completion_tokens: usage
|
|
552
|
+
.get("completion_tokens")
|
|
553
|
+
.and_then(JsonValue::as_u64)
|
|
554
|
+
.unwrap_or(0) as u32,
|
|
555
|
+
total_tokens: usage
|
|
556
|
+
.get("total_tokens")
|
|
557
|
+
.and_then(JsonValue::as_u64)
|
|
558
|
+
.unwrap_or(0) as u32,
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
let latency_ms = (now_millis() - started).max(0.0) as u32;
|
|
562
|
+
|
|
563
|
+
let result = CompletionResult {
|
|
564
|
+
id: payload
|
|
565
|
+
.get("id")
|
|
566
|
+
.and_then(JsonValue::as_str)
|
|
567
|
+
.unwrap_or_default()
|
|
568
|
+
.to_string(),
|
|
569
|
+
model: payload
|
|
570
|
+
.get("model")
|
|
571
|
+
.and_then(JsonValue::as_str)
|
|
572
|
+
.unwrap_or(&model)
|
|
573
|
+
.to_string(),
|
|
574
|
+
role: choice
|
|
575
|
+
.get("message")
|
|
576
|
+
.and_then(|m| m.get("role"))
|
|
577
|
+
.and_then(JsonValue::as_str)
|
|
578
|
+
.unwrap_or("assistant")
|
|
579
|
+
.to_string(),
|
|
580
|
+
content,
|
|
581
|
+
tool_calls,
|
|
582
|
+
finish_reason: choice
|
|
583
|
+
.get("finish_reason")
|
|
584
|
+
.and_then(JsonValue::as_str)
|
|
585
|
+
.map(str::to_string),
|
|
586
|
+
usage: usage_obj,
|
|
587
|
+
usage_available: usage.is_object(),
|
|
588
|
+
latency_ms,
|
|
589
|
+
raw: serde_json::to_string(&payload).ok(),
|
|
590
|
+
healed: None,
|
|
591
|
+
coerced: None,
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
serde_wasm_bindgen::to_value(&result)
|
|
595
|
+
.map_err(|_| js_error("failed to serialize completion result"))
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
#[wasm_bindgen(js_name = streamEvents)]
|
|
599
|
+
pub async fn stream_events(
|
|
600
|
+
&self,
|
|
601
|
+
model: String,
|
|
602
|
+
prompt_or_messages: JsValue,
|
|
603
|
+
on_event: Function,
|
|
604
|
+
options: Option<JsValue>,
|
|
605
|
+
) -> Result<JsValue, JsValue> {
|
|
606
|
+
let opts = if let Some(value) = options {
|
|
607
|
+
serde_wasm_bindgen::from_value::<CompleteOptions>(value)
|
|
608
|
+
.map_err(|_| config_error("invalid options object"))?
|
|
609
|
+
} else {
|
|
610
|
+
CompleteOptions::default()
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
let messages = to_messages(prompt_or_messages)?;
|
|
614
|
+
let messages_value = serde_json::to_value(messages)
|
|
615
|
+
.map_err(|_| js_error("failed to serialize request messages"))?;
|
|
616
|
+
let body = json!({
|
|
617
|
+
"model": model,
|
|
618
|
+
"messages": messages_value,
|
|
619
|
+
"max_tokens": opts.max_tokens,
|
|
620
|
+
"temperature": opts.temperature,
|
|
621
|
+
"top_p": opts.top_p,
|
|
622
|
+
"stream": true
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
let started = now_millis();
|
|
626
|
+
let mut headers = self.headers.clone();
|
|
627
|
+
headers.insert(
|
|
628
|
+
"Authorization".to_string(),
|
|
629
|
+
format!("Bearer {}", self.api_key),
|
|
630
|
+
);
|
|
631
|
+
if self.provider == "openrouter" {
|
|
632
|
+
headers
|
|
633
|
+
.entry("HTTP-Referer".to_string())
|
|
634
|
+
.or_insert_with(|| "https://simpleagents.dev".to_string());
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
let response = js_fetch(
|
|
638
|
+
&format!("{}/chat/completions", self.base_url),
|
|
639
|
+
&body,
|
|
640
|
+
&headers,
|
|
641
|
+
)
|
|
642
|
+
.await?;
|
|
643
|
+
let ok = Reflect::get(&response, &JsValue::from_str("ok"))
|
|
644
|
+
.ok()
|
|
645
|
+
.and_then(|v| v.as_bool())
|
|
646
|
+
.unwrap_or(false);
|
|
647
|
+
if !ok {
|
|
648
|
+
let status = Reflect::get(&response, &JsValue::from_str("status"))
|
|
649
|
+
.ok()
|
|
650
|
+
.and_then(|v| v.as_f64())
|
|
651
|
+
.unwrap_or(0.0) as u16;
|
|
652
|
+
let text_js = call_method0(&response, "text").await?;
|
|
653
|
+
let text = text_js.as_string().unwrap_or_default();
|
|
654
|
+
let message = format!(
|
|
655
|
+
"request failed ({status}): {}",
|
|
656
|
+
text.chars().take(500).collect::<String>()
|
|
657
|
+
);
|
|
658
|
+
let err_event = json!({
|
|
659
|
+
"eventType": "error",
|
|
660
|
+
"error": { "message": message }
|
|
661
|
+
});
|
|
662
|
+
let event_js = serde_wasm_bindgen::to_value(&err_event)
|
|
663
|
+
.map_err(|_| js_error("failed to serialize stream error event"))?;
|
|
664
|
+
on_event
|
|
665
|
+
.call1(&JsValue::NULL, &event_js)
|
|
666
|
+
.map_err(|_| js_error("failed to call stream callback"))?;
|
|
667
|
+
return Err(js_error(message));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
let text_js = call_method0(&response, "text").await?;
|
|
671
|
+
let text = text_js.as_string().unwrap_or_default();
|
|
672
|
+
|
|
673
|
+
let mut aggregate = String::new();
|
|
674
|
+
let mut response_id = String::new();
|
|
675
|
+
let mut response_model = model.clone();
|
|
676
|
+
let mut finish_reason: Option<String> = None;
|
|
677
|
+
|
|
678
|
+
for block in parse_sse_blocks(&text) {
|
|
679
|
+
let Some(data) = parse_sse_data_line(&block) else {
|
|
680
|
+
continue;
|
|
681
|
+
};
|
|
682
|
+
if data == "[DONE]" {
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
let Ok(chunk) = serde_json::from_str::<JsonValue>(&data) else {
|
|
687
|
+
continue;
|
|
688
|
+
};
|
|
689
|
+
let choice = chunk
|
|
690
|
+
.get("choices")
|
|
691
|
+
.and_then(JsonValue::as_array)
|
|
692
|
+
.and_then(|arr| arr.first())
|
|
693
|
+
.cloned()
|
|
694
|
+
.unwrap_or(JsonValue::Null);
|
|
695
|
+
|
|
696
|
+
let delta_content = choice
|
|
697
|
+
.get("delta")
|
|
698
|
+
.and_then(|d| d.get("content"))
|
|
699
|
+
.and_then(JsonValue::as_str)
|
|
700
|
+
.map(str::to_string);
|
|
701
|
+
let delta_role = choice
|
|
702
|
+
.get("delta")
|
|
703
|
+
.and_then(|d| d.get("role"))
|
|
704
|
+
.and_then(JsonValue::as_str)
|
|
705
|
+
.map(str::to_string);
|
|
706
|
+
let chunk_id = chunk
|
|
707
|
+
.get("id")
|
|
708
|
+
.and_then(JsonValue::as_str)
|
|
709
|
+
.unwrap_or_default()
|
|
710
|
+
.to_string();
|
|
711
|
+
let chunk_model = chunk
|
|
712
|
+
.get("model")
|
|
713
|
+
.and_then(JsonValue::as_str)
|
|
714
|
+
.unwrap_or(&response_model)
|
|
715
|
+
.to_string();
|
|
716
|
+
|
|
717
|
+
if response_id.is_empty() && !chunk_id.is_empty() {
|
|
718
|
+
response_id = chunk_id.clone();
|
|
719
|
+
}
|
|
720
|
+
response_model = chunk_model.clone();
|
|
721
|
+
if let Some(content) = delta_content.clone() {
|
|
722
|
+
aggregate.push_str(&content);
|
|
723
|
+
}
|
|
724
|
+
finish_reason = choice
|
|
725
|
+
.get("finish_reason")
|
|
726
|
+
.and_then(JsonValue::as_str)
|
|
727
|
+
.map(str::to_string)
|
|
728
|
+
.or(finish_reason);
|
|
729
|
+
|
|
730
|
+
let delta_event = json!({
|
|
731
|
+
"eventType": "delta",
|
|
732
|
+
"delta": {
|
|
733
|
+
"id": chunk_id,
|
|
734
|
+
"model": chunk_model,
|
|
735
|
+
"index": choice.get("index").and_then(JsonValue::as_u64).unwrap_or(0),
|
|
736
|
+
"role": delta_role,
|
|
737
|
+
"content": delta_content,
|
|
738
|
+
"finishReason": choice.get("finish_reason").and_then(JsonValue::as_str),
|
|
739
|
+
"raw": data,
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
let event_js = serde_wasm_bindgen::to_value(&delta_event)
|
|
743
|
+
.map_err(|_| js_error("failed to serialize stream delta event"))?;
|
|
744
|
+
on_event
|
|
745
|
+
.call1(&JsValue::NULL, &event_js)
|
|
746
|
+
.map_err(|_| js_error("failed to call stream callback"))?;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
let done_event = json!({ "eventType": "done" });
|
|
750
|
+
let done_js = serde_wasm_bindgen::to_value(&done_event)
|
|
751
|
+
.map_err(|_| js_error("failed to serialize stream done event"))?;
|
|
752
|
+
on_event
|
|
753
|
+
.call1(&JsValue::NULL, &done_js)
|
|
754
|
+
.map_err(|_| js_error("failed to call stream callback"))?;
|
|
755
|
+
|
|
756
|
+
let latency_ms = (now_millis() - started).max(0.0) as u32;
|
|
757
|
+
let result = CompletionResult {
|
|
758
|
+
id: response_id,
|
|
759
|
+
model: response_model,
|
|
760
|
+
role: "assistant".to_string(),
|
|
761
|
+
content: Some(aggregate),
|
|
762
|
+
tool_calls: None,
|
|
763
|
+
finish_reason,
|
|
764
|
+
usage: CompletionUsage {
|
|
765
|
+
prompt_tokens: 0,
|
|
766
|
+
completion_tokens: 0,
|
|
767
|
+
total_tokens: 0,
|
|
768
|
+
},
|
|
769
|
+
usage_available: false,
|
|
770
|
+
latency_ms,
|
|
771
|
+
raw: None,
|
|
772
|
+
healed: None,
|
|
773
|
+
coerced: None,
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
serde_wasm_bindgen::to_value(&result)
|
|
777
|
+
.map_err(|_| js_error("failed to serialize stream completion result"))
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
#[wasm_bindgen(js_name = runWorkflowYamlString)]
|
|
781
|
+
pub async fn run_workflow_yaml_string(
|
|
782
|
+
&self,
|
|
783
|
+
yaml_text: String,
|
|
784
|
+
workflow_input: JsValue,
|
|
785
|
+
workflow_options: Option<JsValue>,
|
|
786
|
+
) -> Result<JsValue, JsValue> {
|
|
787
|
+
let doc: WorkflowDoc = serde_yaml::from_str(&yaml_text)
|
|
788
|
+
.map_err(|error| config_error(format!("invalid workflow YAML: {error}")))?;
|
|
789
|
+
if doc.steps.is_empty() {
|
|
790
|
+
return Err(config_error(
|
|
791
|
+
"workflow YAML must contain a non-empty steps array",
|
|
792
|
+
));
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
let mut context: JsonMap<String, JsonValue> =
|
|
796
|
+
serde_wasm_bindgen::from_value(workflow_input)
|
|
797
|
+
.map_err(|_| config_error("workflowInput must be an object"))?;
|
|
798
|
+
|
|
799
|
+
let mut options = WorkflowRunOptions {
|
|
800
|
+
telemetry: None,
|
|
801
|
+
trace: None,
|
|
802
|
+
functions_js: None,
|
|
803
|
+
};
|
|
804
|
+
if let Some(options_js) = workflow_options {
|
|
805
|
+
options = serde_wasm_bindgen::from_value(options_js.clone())
|
|
806
|
+
.map_err(|_| config_error("invalid workflowOptions object"))?;
|
|
807
|
+
let functions_value = Reflect::get(&options_js, &JsValue::from_str("functions")).ok();
|
|
808
|
+
options.functions_js = functions_value;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
let mut index_by_id = HashMap::new();
|
|
812
|
+
for (index, step) in doc.steps.iter().enumerate() {
|
|
813
|
+
if step.id.trim().is_empty() || step.step_type.trim().is_empty() {
|
|
814
|
+
return Err(config_error(format!(
|
|
815
|
+
"workflow step at index {index} requires id and type"
|
|
816
|
+
)));
|
|
817
|
+
}
|
|
818
|
+
index_by_id.insert(step.id.clone(), index);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
let mut events = Vec::new();
|
|
822
|
+
let mut output: Option<JsonValue> = None;
|
|
823
|
+
let mut pointer = 0usize;
|
|
824
|
+
let mut iterations = 0usize;
|
|
825
|
+
|
|
826
|
+
while pointer < doc.steps.len() {
|
|
827
|
+
iterations += 1;
|
|
828
|
+
if iterations > 1000 {
|
|
829
|
+
return Err(js_error("workflow exceeded maximum step iterations"));
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
let step = doc
|
|
833
|
+
.steps
|
|
834
|
+
.get(pointer)
|
|
835
|
+
.cloned()
|
|
836
|
+
.ok_or_else(|| js_error("workflow step index out of range"))?;
|
|
837
|
+
|
|
838
|
+
events.push(WorkflowRunEvent {
|
|
839
|
+
step_id: step.id.clone(),
|
|
840
|
+
step_type: step.step_type.clone(),
|
|
841
|
+
status: "started".to_string(),
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
match step.step_type.as_str() {
|
|
845
|
+
"set" => {
|
|
846
|
+
let key = step.key.ok_or_else(|| {
|
|
847
|
+
config_error(format!("set step '{}' requires key", step.id))
|
|
848
|
+
})?;
|
|
849
|
+
let value = interpolate_json(&step.value.unwrap_or(JsonValue::Null), &context);
|
|
850
|
+
context.insert(key, value);
|
|
851
|
+
}
|
|
852
|
+
"llm_call" => {
|
|
853
|
+
let model = step
|
|
854
|
+
.model
|
|
855
|
+
.or_else(|| doc.model.clone())
|
|
856
|
+
.or_else(|| context.get("model").and_then(JsonValue::as_str).map(str::to_string))
|
|
857
|
+
.ok_or_else(|| {
|
|
858
|
+
config_error(format!(
|
|
859
|
+
"llm_call step '{}' requires model via step.model, workflow model, or workflowInput.model",
|
|
860
|
+
step.id
|
|
861
|
+
))
|
|
862
|
+
})?;
|
|
863
|
+
let prompt =
|
|
864
|
+
interpolate_string(step.prompt.as_deref().unwrap_or_default(), &context);
|
|
865
|
+
let opts = json!({ "temperature": step.temperature });
|
|
866
|
+
let completion_js = self
|
|
867
|
+
.complete(
|
|
868
|
+
model,
|
|
869
|
+
JsValue::from_str(&prompt),
|
|
870
|
+
Some(
|
|
871
|
+
serde_wasm_bindgen::to_value(&opts).map_err(|_| {
|
|
872
|
+
js_error("failed to serialize completion options")
|
|
873
|
+
})?,
|
|
874
|
+
),
|
|
875
|
+
)
|
|
876
|
+
.await?;
|
|
877
|
+
let completion: JsonValue = serde_wasm_bindgen::from_value(completion_js)
|
|
878
|
+
.map_err(|_| js_error("failed to parse completion result"))?;
|
|
879
|
+
let content = completion
|
|
880
|
+
.get("content")
|
|
881
|
+
.cloned()
|
|
882
|
+
.unwrap_or_else(|| JsonValue::String(String::new()));
|
|
883
|
+
context.insert(step.id.clone(), content);
|
|
884
|
+
}
|
|
885
|
+
"if" => {
|
|
886
|
+
let condition = step.condition.ok_or_else(|| {
|
|
887
|
+
config_error(format!("if step '{}' requires condition", step.id))
|
|
888
|
+
})?;
|
|
889
|
+
let matched = evaluate_condition(&condition, &context);
|
|
890
|
+
let target_id = if matched { step.then } else { step.r#else };
|
|
891
|
+
if let Some(target) = target_id {
|
|
892
|
+
let jump_to = index_by_id.get(&target).copied().ok_or_else(|| {
|
|
893
|
+
config_error(format!(
|
|
894
|
+
"if step '{}' points to unknown step '{}'",
|
|
895
|
+
step.id, target
|
|
896
|
+
))
|
|
897
|
+
})?;
|
|
898
|
+
events.push(WorkflowRunEvent {
|
|
899
|
+
step_id: step.id,
|
|
900
|
+
step_type: step.step_type,
|
|
901
|
+
status: "completed".to_string(),
|
|
902
|
+
});
|
|
903
|
+
pointer = jump_to;
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
"call_function" => {
|
|
908
|
+
let function_name = step.function.ok_or_else(|| {
|
|
909
|
+
config_error(format!(
|
|
910
|
+
"call_function step '{}' requires function",
|
|
911
|
+
step.id
|
|
912
|
+
))
|
|
913
|
+
})?;
|
|
914
|
+
let functions_js = options.functions_js.clone().ok_or_else(|| {
|
|
915
|
+
config_error(
|
|
916
|
+
"workflowOptions.functions is required for call_function steps",
|
|
917
|
+
)
|
|
918
|
+
})?;
|
|
919
|
+
let function_value =
|
|
920
|
+
Reflect::get(&functions_js, &JsValue::from_str(&function_name)).map_err(
|
|
921
|
+
|_| {
|
|
922
|
+
config_error(format!(
|
|
923
|
+
"failed to resolve function '{}' from workflowOptions.functions",
|
|
924
|
+
function_name
|
|
925
|
+
))
|
|
926
|
+
},
|
|
927
|
+
)?;
|
|
928
|
+
let function = function_value.dyn_into::<Function>().map_err(|_| {
|
|
929
|
+
config_error(format!(
|
|
930
|
+
"call_function step '{}' references unknown function '{}'",
|
|
931
|
+
step.id, function_name
|
|
932
|
+
))
|
|
933
|
+
})?;
|
|
934
|
+
|
|
935
|
+
let args_value = interpolate_json(
|
|
936
|
+
&step
|
|
937
|
+
.args
|
|
938
|
+
.unwrap_or_else(|| JsonValue::Object(JsonMap::new())),
|
|
939
|
+
&context,
|
|
940
|
+
);
|
|
941
|
+
let args_js = serde_wasm_bindgen::to_value(&args_value)
|
|
942
|
+
.map_err(|_| js_error("failed to serialize call_function args"))?;
|
|
943
|
+
let context_js =
|
|
944
|
+
serde_wasm_bindgen::to_value(&JsonValue::Object(context.clone()))
|
|
945
|
+
.map_err(|_| js_error("failed to serialize workflow context"))?;
|
|
946
|
+
let call_output = function
|
|
947
|
+
.call2(&JsValue::NULL, &args_js, &context_js)
|
|
948
|
+
.map_err(|_| {
|
|
949
|
+
js_error(format!(
|
|
950
|
+
"call_function step '{}' failed for function '{}'",
|
|
951
|
+
step.id, function_name
|
|
952
|
+
))
|
|
953
|
+
})?;
|
|
954
|
+
let resolved_output = if call_output.is_instance_of::<Promise>() {
|
|
955
|
+
JsFuture::from(call_output.unchecked_into::<Promise>())
|
|
956
|
+
.await
|
|
957
|
+
.map_err(|_| js_error("async call_function promise rejected"))?
|
|
958
|
+
} else {
|
|
959
|
+
call_output
|
|
960
|
+
};
|
|
961
|
+
let output_json =
|
|
962
|
+
serde_wasm_bindgen::from_value(resolved_output).unwrap_or(JsonValue::Null);
|
|
963
|
+
context.insert(step.id.clone(), output_json);
|
|
964
|
+
}
|
|
965
|
+
"output" => {
|
|
966
|
+
let source = step
|
|
967
|
+
.text
|
|
968
|
+
.map(JsonValue::String)
|
|
969
|
+
.or(step.value)
|
|
970
|
+
.unwrap_or_else(|| JsonValue::String(String::new()));
|
|
971
|
+
let rendered = interpolate_json(&source, &context);
|
|
972
|
+
output = Some(rendered.clone());
|
|
973
|
+
context.insert(step.id.clone(), rendered);
|
|
974
|
+
}
|
|
975
|
+
other => {
|
|
976
|
+
return Err(config_error(format!(
|
|
977
|
+
"unsupported step type '{}' in simple-agents-wasm",
|
|
978
|
+
other
|
|
979
|
+
)))
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
events.push(WorkflowRunEvent {
|
|
984
|
+
step_id: step.id.clone(),
|
|
985
|
+
step_type: step.step_type.clone(),
|
|
986
|
+
status: "completed".to_string(),
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
if let Some(next) = step.next {
|
|
990
|
+
pointer = index_by_id.get(&next).copied().ok_or_else(|| {
|
|
991
|
+
config_error(format!(
|
|
992
|
+
"step '{}' points to unknown next step '{}'",
|
|
993
|
+
step.id, next
|
|
994
|
+
))
|
|
995
|
+
})?;
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
pointer += 1;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
let result = WorkflowRunResult {
|
|
1003
|
+
status: "ok".to_string(),
|
|
1004
|
+
context: JsonValue::Object(context),
|
|
1005
|
+
output,
|
|
1006
|
+
events,
|
|
1007
|
+
};
|
|
1008
|
+
serde_wasm_bindgen::to_value(&result)
|
|
1009
|
+
.map_err(|_| js_error("failed to serialize workflow result"))
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
#[wasm_bindgen(js_name = runWorkflowYaml)]
|
|
1013
|
+
pub fn run_workflow_yaml(
|
|
1014
|
+
&self,
|
|
1015
|
+
workflow_path: String,
|
|
1016
|
+
_workflow_input: JsValue,
|
|
1017
|
+
) -> Result<JsValue, JsValue> {
|
|
1018
|
+
Err(js_error(format!(
|
|
1019
|
+
"workflow file paths are not supported in browser runtime: {workflow_path}"
|
|
1020
|
+
)))
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
#[wasm_bindgen(js_name = supportsRustWasm)]
|
|
1025
|
+
pub fn supports_rust_wasm() -> bool {
|
|
1026
|
+
true
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
#[wasm_bindgen(js_name = toJsArray)]
|
|
1030
|
+
pub fn to_js_array(value: JsValue) -> Array {
|
|
1031
|
+
let arr = Array::new();
|
|
1032
|
+
arr.push(&value);
|
|
1033
|
+
arr
|
|
1034
|
+
}
|