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.
@@ -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
+ }