simple-agents-wasm 0.2.28 → 0.2.30

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/rust/src/lib.rs CHANGED
@@ -20,7 +20,9 @@ struct MessageInput {
20
20
  role: String,
21
21
  content: String,
22
22
  name: Option<String>,
23
+ #[serde(alias = "tool_call_id")]
23
24
  tool_call_id: Option<String>,
25
+ #[serde(alias = "tool_calls")]
24
26
  tool_calls: Option<Vec<JsToolCall>>,
25
27
  }
26
28
 
@@ -28,6 +30,7 @@ struct MessageInput {
28
30
  #[serde(rename_all = "camelCase")]
29
31
  struct JsToolCall {
30
32
  id: String,
33
+ #[serde(alias = "type")]
31
34
  tool_type: Option<String>,
32
35
  function: JsToolCallFunction,
33
36
  }
@@ -38,6 +41,32 @@ struct JsToolCallFunction {
38
41
  arguments: String,
39
42
  }
40
43
 
44
+ #[derive(Serialize, Clone)]
45
+ struct OpenAiMessageInput {
46
+ role: String,
47
+ content: String,
48
+ #[serde(skip_serializing_if = "Option::is_none")]
49
+ name: Option<String>,
50
+ #[serde(skip_serializing_if = "Option::is_none")]
51
+ tool_call_id: Option<String>,
52
+ #[serde(skip_serializing_if = "Option::is_none")]
53
+ tool_calls: Option<Vec<OpenAiToolCall>>,
54
+ }
55
+
56
+ #[derive(Serialize, Clone)]
57
+ struct OpenAiToolCall {
58
+ id: String,
59
+ #[serde(rename = "type")]
60
+ tool_type: String,
61
+ function: OpenAiToolCallFunction,
62
+ }
63
+
64
+ #[derive(Serialize, Clone)]
65
+ struct OpenAiToolCallFunction {
66
+ name: String,
67
+ arguments: String,
68
+ }
69
+
41
70
  #[derive(Deserialize, Default, Clone)]
42
71
  #[serde(rename_all = "camelCase")]
43
72
  struct CompleteOptions {
@@ -53,6 +82,66 @@ struct WorkflowDoc {
53
82
  steps: Vec<WorkflowStep>,
54
83
  }
55
84
 
85
+ #[derive(Deserialize, Clone)]
86
+ struct GraphWorkflowDoc {
87
+ model: Option<String>,
88
+ entry_node: String,
89
+ nodes: Vec<GraphWorkflowNode>,
90
+ edges: Option<Vec<GraphWorkflowEdge>>,
91
+ }
92
+
93
+ #[derive(Deserialize, Clone)]
94
+ struct GraphWorkflowEdge {
95
+ from: String,
96
+ to: String,
97
+ }
98
+
99
+ #[derive(Deserialize, Clone)]
100
+ struct GraphWorkflowNode {
101
+ id: String,
102
+ node_type: GraphNodeType,
103
+ config: Option<GraphNodeConfig>,
104
+ }
105
+
106
+ #[derive(Deserialize, Clone)]
107
+ struct GraphNodeConfig {
108
+ prompt: Option<String>,
109
+ payload: Option<JsonValue>,
110
+ output_schema: Option<JsonValue>,
111
+ }
112
+
113
+ #[derive(Deserialize, Clone, Default)]
114
+ struct GraphNodeType {
115
+ llm_call: Option<GraphLlmCall>,
116
+ switch: Option<GraphSwitch>,
117
+ custom_worker: Option<GraphCustomWorker>,
118
+ }
119
+
120
+ #[derive(Deserialize, Clone)]
121
+ struct GraphLlmCall {
122
+ model: Option<String>,
123
+ temperature: Option<f64>,
124
+ messages_path: Option<String>,
125
+ append_prompt_as_user: Option<bool>,
126
+ }
127
+
128
+ #[derive(Deserialize, Clone)]
129
+ struct GraphSwitch {
130
+ branches: Option<Vec<GraphSwitchBranch>>,
131
+ default: Option<String>,
132
+ }
133
+
134
+ #[derive(Deserialize, Clone)]
135
+ struct GraphSwitchBranch {
136
+ condition: Option<String>,
137
+ target: Option<String>,
138
+ }
139
+
140
+ #[derive(Deserialize, Clone)]
141
+ struct GraphCustomWorker {
142
+ handler: Option<String>,
143
+ }
144
+
56
145
  #[derive(Deserialize, Clone)]
57
146
  struct WorkflowStep {
58
147
  id: String,
@@ -201,6 +290,33 @@ fn to_messages(prompt_or_messages: JsValue) -> Result<Vec<MessageInput>, JsValue
201
290
  Ok(messages)
202
291
  }
203
292
 
293
+ fn to_openai_messages(messages: Vec<MessageInput>) -> Vec<OpenAiMessageInput> {
294
+ messages
295
+ .into_iter()
296
+ .map(|message| OpenAiMessageInput {
297
+ role: message.role,
298
+ content: message.content,
299
+ name: message.name,
300
+ tool_call_id: message.tool_call_id,
301
+ tool_calls: message.tool_calls.map(|tool_calls| {
302
+ tool_calls
303
+ .into_iter()
304
+ .map(|tool_call| OpenAiToolCall {
305
+ id: tool_call.id,
306
+ tool_type: tool_call
307
+ .tool_type
308
+ .unwrap_or_else(|| "function".to_string()),
309
+ function: OpenAiToolCallFunction {
310
+ name: tool_call.function.name,
311
+ arguments: tool_call.function.arguments,
312
+ },
313
+ })
314
+ .collect()
315
+ }),
316
+ })
317
+ .collect()
318
+ }
319
+
204
320
  fn normalize_base_url(base_url: &str) -> String {
205
321
  base_url.trim_end_matches('/').to_string()
206
322
  }
@@ -230,6 +346,33 @@ async fn call_method0(target: &JsValue, method: &str) -> Result<JsValue, JsValue
230
346
  .map_err(|_| js_error(format!("await failed for method: {method}")))
231
347
  }
232
348
 
349
+ fn call_method0_sync(target: &JsValue, method: &str) -> Result<JsValue, JsValue> {
350
+ let method_value = Reflect::get(target, &JsValue::from_str(method))
351
+ .map_err(|_| js_error(format!("missing method: {method}")))?;
352
+ let method_fn = method_value
353
+ .dyn_into::<Function>()
354
+ .map_err(|_| js_error(format!("method is not callable: {method}")))?;
355
+ method_fn
356
+ .call0(target)
357
+ .map_err(|_| js_error(format!("failed to call method: {method}")))
358
+ }
359
+
360
+ fn call_method2_sync(
361
+ target: &JsValue,
362
+ method: &str,
363
+ arg1: &JsValue,
364
+ arg2: &JsValue,
365
+ ) -> Result<JsValue, JsValue> {
366
+ let method_value = Reflect::get(target, &JsValue::from_str(method))
367
+ .map_err(|_| js_error(format!("missing method: {method}")))?;
368
+ let method_fn = method_value
369
+ .dyn_into::<Function>()
370
+ .map_err(|_| js_error(format!("method is not callable: {method}")))?;
371
+ method_fn
372
+ .call2(target, arg1, arg2)
373
+ .map_err(|_| js_error(format!("failed to call method: {method}")))
374
+ }
375
+
233
376
  async fn js_fetch(
234
377
  url: &str,
235
378
  body: &JsonValue,
@@ -356,18 +499,369 @@ fn evaluate_condition(condition: &WorkflowCondition, context: &JsonMap<String, J
356
499
  }
357
500
  }
358
501
 
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()
502
+ fn get_path_value<'a>(root: &'a JsonValue, path: &str) -> Option<&'a JsonValue> {
503
+ let normalized = path.trim().strip_prefix("$.").unwrap_or(path.trim());
504
+ let mut current = root;
505
+ for token in normalized.split('.') {
506
+ if token.is_empty() {
507
+ continue;
508
+ }
509
+ current = current.get(token)?;
510
+ }
511
+ Some(current)
512
+ }
513
+
514
+ fn interpolate_graph_prompt(input: &str, context: &JsonValue) -> String {
515
+ let mut output = String::new();
516
+ let mut rest = input;
517
+
518
+ while let Some(start) = rest.find("{{") {
519
+ output.push_str(&rest[..start]);
520
+ let after_start = &rest[start + 2..];
521
+ if let Some(end) = after_start.find("}}") {
522
+ let key = after_start[..end].trim();
523
+ let replacement = get_path_value(context, key)
524
+ .map(|value| match value {
525
+ JsonValue::String(s) => s.clone(),
526
+ _ => serde_json::to_string(value).unwrap_or_default(),
527
+ })
528
+ .unwrap_or_default();
529
+ output.push_str(&replacement);
530
+ rest = &after_start[end + 2..];
531
+ } else {
532
+ output.push_str(&rest[start..]);
533
+ rest = "";
534
+ }
535
+ }
536
+
537
+ output.push_str(rest);
538
+ output
539
+ }
540
+
541
+ fn parse_json_from_text(value: &str) -> JsonValue {
542
+ if let Ok(parsed) = serde_json::from_str::<JsonValue>(value) {
543
+ return parsed;
544
+ }
545
+
546
+ if let (Some(start), Some(end)) = (value.find('{'), value.rfind('}')) {
547
+ if end > start {
548
+ let candidate = &value[start..=end];
549
+ if let Ok(parsed) = serde_json::from_str::<JsonValue>(candidate) {
550
+ return parsed;
551
+ }
552
+ }
553
+ }
554
+
555
+ JsonValue::String(value.to_string())
556
+ }
557
+
558
+ fn evaluate_switch_condition(condition: &str, context: &JsonValue) -> bool {
559
+ let trimmed = condition.trim();
560
+
561
+ if let Some((left, right)) = trimmed.split_once("==") {
562
+ let left_path = left.trim();
563
+ let right_value = right.trim().trim_matches('"');
564
+ let left_value = get_path_value(context, left_path)
565
+ .map(|value| {
566
+ value
567
+ .as_str()
568
+ .map(str::to_string)
569
+ .unwrap_or_else(|| value.to_string())
570
+ })
571
+ .unwrap_or_default();
572
+ return left_value == right_value;
573
+ }
574
+
575
+ if let Some((left, right)) = trimmed.split_once("!=") {
576
+ let left_path = left.trim();
577
+ let right_value = right.trim().trim_matches('"');
578
+ let left_value = get_path_value(context, left_path)
579
+ .map(|value| {
580
+ value
581
+ .as_str()
582
+ .map(str::to_string)
583
+ .unwrap_or_else(|| value.to_string())
584
+ })
585
+ .unwrap_or_default();
586
+ return left_value != right_value;
587
+ }
588
+
589
+ false
590
+ }
591
+
592
+ fn graph_llm_output_schema(config: Option<&GraphNodeConfig>) -> JsonValue {
593
+ if let Some(schema) = config.and_then(|cfg| cfg.output_schema.clone()) {
594
+ return schema;
595
+ }
596
+ json!({
597
+ "type": "object",
598
+ "additionalProperties": true
599
+ })
600
+ }
601
+
602
+ fn matches_json_schema_type(expected: &str, value: &JsonValue) -> bool {
603
+ match expected {
604
+ "null" => value.is_null(),
605
+ "boolean" => value.is_boolean(),
606
+ "number" => value.is_number(),
607
+ "integer" => value.as_i64().is_some() || value.as_u64().is_some(),
608
+ "string" => value.is_string(),
609
+ "array" => value.is_array(),
610
+ "object" => value.is_object(),
611
+ _ => false,
612
+ }
613
+ }
614
+
615
+ fn json_value_type_name(value: &JsonValue) -> &'static str {
616
+ match value {
617
+ JsonValue::Null => "null",
618
+ JsonValue::Bool(_) => "boolean",
619
+ JsonValue::Number(_) => "number",
620
+ JsonValue::String(_) => "string",
621
+ JsonValue::Array(_) => "array",
622
+ JsonValue::Object(_) => "object",
623
+ }
624
+ }
625
+
626
+ fn validate_schema_instance(
627
+ schema: &JsonValue,
628
+ value: &JsonValue,
629
+ path: &str,
630
+ ) -> Result<(), String> {
631
+ let Some(schema_obj) = schema.as_object() else {
632
+ return Ok(());
633
+ };
634
+
635
+ if let Some(any_of) = schema_obj.get("anyOf").and_then(JsonValue::as_array) {
636
+ if !any_of.is_empty()
637
+ && !any_of
638
+ .iter()
639
+ .any(|nested| validate_schema_instance(nested, value, path).is_ok())
640
+ {
641
+ return Err(format!("{path} did not satisfy anyOf"));
642
+ }
643
+ }
644
+
645
+ if let Some(one_of) = schema_obj.get("oneOf").and_then(JsonValue::as_array) {
646
+ if !one_of.is_empty() {
647
+ let matched = one_of
648
+ .iter()
649
+ .filter(|nested| validate_schema_instance(nested, value, path).is_ok())
650
+ .count();
651
+ if matched != 1 {
652
+ return Err(format!("{path} must satisfy exactly one oneOf schema"));
653
+ }
654
+ }
655
+ }
656
+
657
+ if let Some(all_of) = schema_obj.get("allOf").and_then(JsonValue::as_array) {
658
+ for nested in all_of {
659
+ validate_schema_instance(nested, value, path)?;
660
+ }
661
+ }
662
+
663
+ if let Some(enum_values) = schema_obj.get("enum").and_then(JsonValue::as_array) {
664
+ if !enum_values.is_empty() && !enum_values.iter().any(|candidate| candidate == value) {
665
+ return Err(format!("{path} must be one of enum values"));
666
+ }
667
+ }
668
+
669
+ if let Some(schema_type) = schema_obj.get("type") {
670
+ let matches = match schema_type {
671
+ JsonValue::String(t) => matches_json_schema_type(t, value),
672
+ JsonValue::Array(types) => types.iter().any(|item| {
673
+ item.as_str()
674
+ .map(|t| matches_json_schema_type(t, value))
675
+ .unwrap_or(false)
676
+ }),
677
+ _ => true,
678
+ };
679
+ if !matches {
680
+ return Err(format!(
681
+ "{path} expected type {schema_type}, got {}",
682
+ json_value_type_name(value)
683
+ ));
684
+ }
685
+ }
686
+
687
+ if let Some(text) = value.as_str() {
688
+ if let Some(min_length) = schema_obj.get("minLength").and_then(JsonValue::as_u64) {
689
+ if text.chars().count() < min_length as usize {
690
+ return Err(format!("{path} must have minLength {min_length}"));
691
+ }
692
+ }
693
+ if let Some(max_length) = schema_obj.get("maxLength").and_then(JsonValue::as_u64) {
694
+ if text.chars().count() > max_length as usize {
695
+ return Err(format!("{path} must have maxLength {max_length}"));
696
+ }
697
+ }
698
+ }
699
+
700
+ if let Some(number) = value.as_f64() {
701
+ if let Some(minimum) = schema_obj.get("minimum").and_then(JsonValue::as_f64) {
702
+ if number < minimum {
703
+ return Err(format!("{path} must be >= {minimum}"));
704
+ }
705
+ }
706
+ if let Some(maximum) = schema_obj.get("maximum").and_then(JsonValue::as_f64) {
707
+ if number > maximum {
708
+ return Err(format!("{path} must be <= {maximum}"));
709
+ }
710
+ }
711
+ }
712
+
713
+ if let Some(items) = value.as_array() {
714
+ if let Some(min_items) = schema_obj.get("minItems").and_then(JsonValue::as_u64) {
715
+ if items.len() < min_items as usize {
716
+ return Err(format!("{path} must have at least {min_items} items"));
717
+ }
718
+ }
719
+ if let Some(max_items) = schema_obj.get("maxItems").and_then(JsonValue::as_u64) {
720
+ if items.len() > max_items as usize {
721
+ return Err(format!("{path} must have at most {max_items} items"));
722
+ }
723
+ }
724
+ if let Some(item_schema) = schema_obj.get("items") {
725
+ for (index, item) in items.iter().enumerate() {
726
+ validate_schema_instance(item_schema, item, &format!("{path}[{index}]"))?;
727
+ }
728
+ }
729
+ }
730
+
731
+ if let Some(object) = value.as_object() {
732
+ let properties = schema_obj
733
+ .get("properties")
734
+ .and_then(JsonValue::as_object)
735
+ .cloned()
736
+ .unwrap_or_default();
737
+
738
+ if let Some(required) = schema_obj.get("required").and_then(JsonValue::as_array) {
739
+ for key in required.iter().filter_map(JsonValue::as_str) {
740
+ if !object.contains_key(key) {
741
+ return Err(format!("{path}.{key} is required"));
742
+ }
743
+ }
744
+ }
745
+
746
+ for (key, property_schema) in &properties {
747
+ if let Some(property_value) = object.get(key) {
748
+ validate_schema_instance(
749
+ property_schema,
750
+ property_value,
751
+ &format!("{path}.{key}"),
752
+ )?;
753
+ }
754
+ }
755
+
756
+ if let Some(additional) = schema_obj.get("additionalProperties") {
757
+ if additional == &JsonValue::Bool(false) {
758
+ for key in object.keys() {
759
+ if !properties.contains_key(key) {
760
+ return Err(format!("{path}.{key} is not allowed"));
761
+ }
762
+ }
763
+ } else if additional != &JsonValue::Bool(true) {
764
+ for (key, property_value) in object {
765
+ if properties.contains_key(key) {
766
+ continue;
767
+ }
768
+ validate_schema_instance(additional, property_value, &format!("{path}.{key}"))?;
769
+ }
770
+ }
771
+ }
772
+ }
773
+
774
+ Ok(())
775
+ }
776
+
777
+ fn normalize_sse_text(input: &str) -> String {
778
+ input.replace("\r\n", "\n").replace('\r', "\n")
779
+ }
780
+
781
+ fn pop_next_sse_block(buffer: &mut String) -> Option<String> {
782
+ let delimiter_index = buffer.find("\n\n")?;
783
+ let block = buffer[..delimiter_index].trim().to_string();
784
+ let remaining = buffer[delimiter_index + 2..].to_string();
785
+ *buffer = remaining;
786
+ Some(block)
787
+ }
788
+
789
+ async fn consume_sse_blocks<F>(response: &JsValue, mut on_block: F) -> Result<(), JsValue>
790
+ where
791
+ F: FnMut(String) -> Result<bool, JsValue>,
792
+ {
793
+ let body = Reflect::get(response, &JsValue::from_str("body"))
794
+ .map_err(|_| js_error("stream response body is unavailable"))?;
795
+ if body.is_null() || body.is_undefined() {
796
+ return Err(js_error("stream response had no body"));
797
+ }
798
+
799
+ let reader = call_method0_sync(&body, "getReader")?;
800
+
801
+ let global = js_sys::global();
802
+ let text_decoder_ctor = Reflect::get(&global, &JsValue::from_str("TextDecoder"))
803
+ .map_err(|_| js_error("TextDecoder is unavailable"))?;
804
+ let text_decoder_fn = text_decoder_ctor
805
+ .dyn_into::<Function>()
806
+ .map_err(|_| js_error("TextDecoder constructor is not callable"))?;
807
+ let decoder = js_sys::Reflect::construct(&text_decoder_fn, &Array::new())
808
+ .map_err(|_| js_error("failed to construct TextDecoder"))?;
809
+
810
+ let mut buffer = String::new();
811
+
812
+ loop {
813
+ let read_result = call_method0(&reader, "read").await?;
814
+ let done = Reflect::get(&read_result, &JsValue::from_str("done"))
815
+ .ok()
816
+ .and_then(|value| value.as_bool())
817
+ .unwrap_or(false);
818
+ if done {
819
+ break;
820
+ }
821
+
822
+ let value = Reflect::get(&read_result, &JsValue::from_str("value"))
823
+ .map_err(|_| js_error("stream chunk missing value"))?;
824
+
825
+ let decode_options = Object::new();
826
+ Reflect::set(
827
+ &decode_options,
828
+ &JsValue::from_str("stream"),
829
+ &JsValue::TRUE,
830
+ )
831
+ .map_err(|_| js_error("failed to configure stream decoder"))?;
832
+
833
+ let chunk_js = call_method2_sync(&decoder, "decode", &value, &decode_options)?;
834
+ let chunk = chunk_js.as_string().unwrap_or_default();
835
+ buffer.push_str(&normalize_sse_text(&chunk));
836
+
837
+ while let Some(block) = pop_next_sse_block(&mut buffer) {
838
+ if block.is_empty() {
839
+ continue;
840
+ }
841
+ let should_continue = on_block(block)?;
842
+ if !should_continue {
843
+ return Ok(());
844
+ }
845
+ }
846
+ }
847
+
848
+ let trailing_js = call_method0_sync(&decoder, "decode")?;
849
+ let trailing = trailing_js.as_string().unwrap_or_default();
850
+ buffer.push_str(&normalize_sse_text(&trailing));
851
+
852
+ let trailing_block = buffer.trim().to_string();
853
+ if !trailing_block.is_empty() {
854
+ let _ = on_block(trailing_block)?;
855
+ }
856
+
857
+ Ok(())
365
858
  }
366
859
 
367
860
  fn parse_sse_data_line(block: &str) -> Option<String> {
368
861
  let mut data_lines = Vec::new();
369
862
  for line in block.lines() {
370
- if let Some(rest) = line.strip_prefix("data:") {
863
+ let normalized = line.trim_end_matches('\r');
864
+ if let Some(rest) = normalized.strip_prefix("data:") {
371
865
  data_lines.push(rest.trim_start().to_string());
372
866
  }
373
867
  }
@@ -441,7 +935,7 @@ impl WasmClient {
441
935
  }
442
936
  }
443
937
 
444
- let messages = to_messages(prompt_or_messages)?;
938
+ let messages = to_openai_messages(to_messages(prompt_or_messages)?);
445
939
  let messages_value = serde_json::to_value(messages)
446
940
  .map_err(|_| js_error("failed to serialize request messages"))?;
447
941
  let body = json!({
@@ -610,7 +1104,7 @@ impl WasmClient {
610
1104
  CompleteOptions::default()
611
1105
  };
612
1106
 
613
- let messages = to_messages(prompt_or_messages)?;
1107
+ let messages = to_openai_messages(to_messages(prompt_or_messages)?);
614
1108
  let messages_value = serde_json::to_value(messages)
615
1109
  .map_err(|_| js_error("failed to serialize request messages"))?;
616
1110
  let body = json!({
@@ -667,24 +1161,21 @@ impl WasmClient {
667
1161
  return Err(js_error(message));
668
1162
  }
669
1163
 
670
- let text_js = call_method0(&response, "text").await?;
671
- let text = text_js.as_string().unwrap_or_default();
672
-
673
1164
  let mut aggregate = String::new();
674
1165
  let mut response_id = String::new();
675
1166
  let mut response_model = model.clone();
676
1167
  let mut finish_reason: Option<String> = None;
677
1168
 
678
- for block in parse_sse_blocks(&text) {
1169
+ consume_sse_blocks(&response, |block| {
679
1170
  let Some(data) = parse_sse_data_line(&block) else {
680
- continue;
1171
+ return Ok(true);
681
1172
  };
682
1173
  if data == "[DONE]" {
683
- break;
1174
+ return Ok(false);
684
1175
  }
685
1176
 
686
1177
  let Ok(chunk) = serde_json::from_str::<JsonValue>(&data) else {
687
- continue;
1178
+ return Ok(true);
688
1179
  };
689
1180
  let choice = chunk
690
1181
  .get("choices")
@@ -725,7 +1216,7 @@ impl WasmClient {
725
1216
  .get("finish_reason")
726
1217
  .and_then(JsonValue::as_str)
727
1218
  .map(str::to_string)
728
- .or(finish_reason);
1219
+ .or(finish_reason.clone());
729
1220
 
730
1221
  let delta_event = json!({
731
1222
  "eventType": "delta",
@@ -744,7 +1235,10 @@ impl WasmClient {
744
1235
  on_event
745
1236
  .call1(&JsValue::NULL, &event_js)
746
1237
  .map_err(|_| js_error("failed to call stream callback"))?;
747
- }
1238
+
1239
+ Ok(true)
1240
+ })
1241
+ .await?;
748
1242
 
749
1243
  let done_event = json!({ "eventType": "done" });
750
1244
  let done_js = serde_wasm_bindgen::to_value(&done_event)
@@ -784,13 +1278,8 @@ impl WasmClient {
784
1278
  workflow_input: JsValue,
785
1279
  workflow_options: Option<JsValue>,
786
1280
  ) -> Result<JsValue, JsValue> {
787
- let doc: WorkflowDoc = serde_yaml::from_str(&yaml_text)
1281
+ let raw_doc: JsonValue = serde_yaml::from_str(&yaml_text)
788
1282
  .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
1283
 
795
1284
  let mut context: JsonMap<String, JsonValue> =
796
1285
  serde_wasm_bindgen::from_value(workflow_input)
@@ -808,6 +1297,284 @@ impl WasmClient {
808
1297
  options.functions_js = functions_value;
809
1298
  }
810
1299
 
1300
+ if raw_doc.get("entry_node").is_some() && raw_doc.get("nodes").is_some() {
1301
+ let graph_doc: GraphWorkflowDoc = serde_json::from_value(raw_doc)
1302
+ .map_err(|error| config_error(format!("invalid graph workflow YAML: {error}")))?;
1303
+
1304
+ let mut node_by_id: HashMap<String, GraphWorkflowNode> = HashMap::new();
1305
+ for node in &graph_doc.nodes {
1306
+ if node.id.trim().is_empty() {
1307
+ return Err(config_error("graph workflow node id cannot be empty"));
1308
+ }
1309
+ node_by_id.insert(node.id.clone(), node.clone());
1310
+ }
1311
+
1312
+ let mut edge_map: HashMap<String, Vec<String>> = HashMap::new();
1313
+ if let Some(edges) = graph_doc.edges.as_ref() {
1314
+ for edge in edges {
1315
+ edge_map
1316
+ .entry(edge.from.clone())
1317
+ .or_default()
1318
+ .push(edge.to.clone());
1319
+ }
1320
+ }
1321
+
1322
+ let mut graph_context = json!({
1323
+ "input": JsonValue::Object(context.clone()),
1324
+ "nodes": JsonValue::Object(JsonMap::new())
1325
+ });
1326
+
1327
+ let mut events = Vec::new();
1328
+ let mut output: Option<JsonValue> = None;
1329
+ let mut pointer = graph_doc.entry_node.clone();
1330
+ let mut iterations = 0usize;
1331
+
1332
+ while !pointer.is_empty() {
1333
+ iterations += 1;
1334
+ if iterations > 1000 {
1335
+ return Err(js_error("workflow exceeded maximum step iterations"));
1336
+ }
1337
+
1338
+ let node = node_by_id.get(&pointer).cloned().ok_or_else(|| {
1339
+ config_error(format!("workflow references unknown node '{}'", pointer))
1340
+ })?;
1341
+
1342
+ let step_type = if node.node_type.llm_call.is_some() {
1343
+ "llm_call"
1344
+ } else if node.node_type.switch.is_some() {
1345
+ "switch"
1346
+ } else if node.node_type.custom_worker.is_some() {
1347
+ "custom_worker"
1348
+ } else {
1349
+ "unknown"
1350
+ };
1351
+
1352
+ events.push(WorkflowRunEvent {
1353
+ step_id: node.id.clone(),
1354
+ step_type: step_type.to_string(),
1355
+ status: "started".to_string(),
1356
+ });
1357
+
1358
+ if let Some(llm) = node.node_type.llm_call.as_ref() {
1359
+ let model = llm
1360
+ .model
1361
+ .clone()
1362
+ .or_else(|| graph_doc.model.clone())
1363
+ .or_else(|| {
1364
+ context
1365
+ .get("model")
1366
+ .and_then(JsonValue::as_str)
1367
+ .map(str::to_string)
1368
+ })
1369
+ .ok_or_else(|| {
1370
+ config_error(format!(
1371
+ "llm_call node '{}' requires node_type.llm_call.model",
1372
+ node.id
1373
+ ))
1374
+ })?;
1375
+
1376
+ let prompt = interpolate_graph_prompt(
1377
+ node.config
1378
+ .as_ref()
1379
+ .and_then(|config| config.prompt.as_deref())
1380
+ .unwrap_or_default(),
1381
+ &graph_context,
1382
+ );
1383
+
1384
+ let prompt_js = if llm.messages_path.as_deref() == Some("input.messages") {
1385
+ let mut history: Vec<MessageInput> =
1386
+ get_path_value(&graph_context, "input.messages")
1387
+ .and_then(|value| {
1388
+ serde_json::from_value::<Vec<MessageInput>>(value.clone()).ok()
1389
+ })
1390
+ .unwrap_or_default();
1391
+ if llm.append_prompt_as_user.unwrap_or(true) {
1392
+ history.push(MessageInput {
1393
+ role: "user".to_string(),
1394
+ content: prompt,
1395
+ name: None,
1396
+ tool_call_id: None,
1397
+ tool_calls: None,
1398
+ });
1399
+ }
1400
+ serde_wasm_bindgen::to_value(&history)
1401
+ .map_err(|_| js_error("failed to serialize graph llm messages"))?
1402
+ } else {
1403
+ JsValue::from_str(&prompt)
1404
+ };
1405
+
1406
+ let opts = json!({ "temperature": llm.temperature });
1407
+ let completion_js = self
1408
+ .complete(
1409
+ model,
1410
+ prompt_js,
1411
+ Some(
1412
+ serde_wasm_bindgen::to_value(&opts).map_err(|_| {
1413
+ js_error("failed to serialize completion options")
1414
+ })?,
1415
+ ),
1416
+ )
1417
+ .await?;
1418
+ let completion: JsonValue = serde_wasm_bindgen::from_value(completion_js)
1419
+ .map_err(|_| js_error("failed to parse completion result"))?;
1420
+
1421
+ let raw_content = completion
1422
+ .get("content")
1423
+ .and_then(JsonValue::as_str)
1424
+ .unwrap_or_default();
1425
+ let parsed_output = parse_json_from_text(raw_content);
1426
+ let output_schema = graph_llm_output_schema(node.config.as_ref());
1427
+ validate_schema_instance(&output_schema, &parsed_output, "$").map_err(
1428
+ |message| {
1429
+ js_error(format!(
1430
+ "llm_call node '{}' output failed schema validation: {}",
1431
+ node.id, message
1432
+ ))
1433
+ },
1434
+ )?;
1435
+
1436
+ if let Some(nodes_map) = graph_context
1437
+ .get_mut("nodes")
1438
+ .and_then(JsonValue::as_object_mut)
1439
+ {
1440
+ nodes_map.insert(
1441
+ node.id.clone(),
1442
+ json!({
1443
+ "output": parsed_output,
1444
+ "raw": raw_content
1445
+ }),
1446
+ );
1447
+ }
1448
+
1449
+ output = Some(parsed_output);
1450
+ pointer = edge_map
1451
+ .get(&node.id)
1452
+ .and_then(|targets| targets.first())
1453
+ .cloned()
1454
+ .unwrap_or_default();
1455
+ } else if let Some(switch) = node.node_type.switch.as_ref() {
1456
+ let mut next_pointer = switch.default.clone().unwrap_or_default();
1457
+ if let Some(branches) = switch.branches.as_ref() {
1458
+ for branch in branches {
1459
+ let matches = branch
1460
+ .condition
1461
+ .as_ref()
1462
+ .map(|condition| {
1463
+ evaluate_switch_condition(condition, &graph_context)
1464
+ })
1465
+ .unwrap_or(false);
1466
+ if matches {
1467
+ next_pointer = branch.target.clone().unwrap_or_default();
1468
+ break;
1469
+ }
1470
+ }
1471
+ }
1472
+ pointer = next_pointer;
1473
+ } else if let Some(custom_worker) = node.node_type.custom_worker.as_ref() {
1474
+ let handler = custom_worker
1475
+ .handler
1476
+ .clone()
1477
+ .unwrap_or_else(|| "custom_worker".to_string());
1478
+ let functions_js = options.functions_js.clone().ok_or_else(|| {
1479
+ config_error(format!(
1480
+ "custom_worker node '{}' requires workflowOptions.functions",
1481
+ node.id
1482
+ ))
1483
+ })?;
1484
+ let function_value = Reflect::get(&functions_js, &JsValue::from_str(&handler))
1485
+ .map_err(|_| {
1486
+ config_error(format!(
1487
+ "failed to resolve custom worker handler '{}' from workflowOptions.functions",
1488
+ handler
1489
+ ))
1490
+ })?;
1491
+ let function = function_value.dyn_into::<Function>().map_err(|_| {
1492
+ config_error(format!(
1493
+ "custom_worker node '{}' requires workflowOptions.functions['{}']",
1494
+ node.id, handler
1495
+ ))
1496
+ })?;
1497
+
1498
+ let worker_args = json!({
1499
+ "handler": handler,
1500
+ "payload": node
1501
+ .config
1502
+ .as_ref()
1503
+ .and_then(|config| config.payload.clone())
1504
+ .unwrap_or(JsonValue::Null),
1505
+ "nodeId": node.id.clone()
1506
+ });
1507
+ let args_js = serde_wasm_bindgen::to_value(&worker_args)
1508
+ .map_err(|_| js_error("failed to serialize custom_worker args"))?;
1509
+ let context_js = serde_wasm_bindgen::to_value(&graph_context)
1510
+ .map_err(|_| js_error("failed to serialize graph context"))?;
1511
+ let worker_call_output = function
1512
+ .call2(&JsValue::NULL, &args_js, &context_js)
1513
+ .map_err(|_| {
1514
+ js_error(format!(
1515
+ "custom_worker node '{}' handler call failed",
1516
+ node.id
1517
+ ))
1518
+ })?;
1519
+ let resolved_output = if worker_call_output.is_instance_of::<Promise>() {
1520
+ JsFuture::from(worker_call_output.unchecked_into::<Promise>())
1521
+ .await
1522
+ .map_err(|_| {
1523
+ js_error(format!(
1524
+ "custom_worker node '{}' async handler promise rejected",
1525
+ node.id
1526
+ ))
1527
+ })?
1528
+ } else {
1529
+ worker_call_output
1530
+ };
1531
+ let worker_output =
1532
+ serde_wasm_bindgen::from_value(resolved_output).unwrap_or(JsonValue::Null);
1533
+
1534
+ if let Some(nodes_map) = graph_context
1535
+ .get_mut("nodes")
1536
+ .and_then(JsonValue::as_object_mut)
1537
+ {
1538
+ nodes_map.insert(node.id.clone(), json!({ "output": worker_output }));
1539
+ }
1540
+
1541
+ output = Some(worker_output);
1542
+ pointer = edge_map
1543
+ .get(&node.id)
1544
+ .and_then(|targets| targets.first())
1545
+ .cloned()
1546
+ .unwrap_or_default();
1547
+ } else {
1548
+ return Err(config_error(
1549
+ "unsupported node_type in simple-agents-wasm graph workflow",
1550
+ ));
1551
+ }
1552
+
1553
+ events.push(WorkflowRunEvent {
1554
+ step_id: node.id,
1555
+ step_type: step_type.to_string(),
1556
+ status: "completed".to_string(),
1557
+ });
1558
+ }
1559
+
1560
+ let result = WorkflowRunResult {
1561
+ status: "ok".to_string(),
1562
+ context: graph_context,
1563
+ output,
1564
+ events,
1565
+ };
1566
+ return serde_wasm_bindgen::to_value(&result)
1567
+ .map_err(|_| js_error("failed to serialize workflow result"));
1568
+ }
1569
+
1570
+ let doc: WorkflowDoc = serde_json::from_value(raw_doc)
1571
+ .map_err(|error| config_error(format!("invalid workflow YAML: {error}")))?;
1572
+ if doc.steps.is_empty() {
1573
+ return Err(config_error(
1574
+ "workflow YAML must contain a non-empty steps array",
1575
+ ));
1576
+ }
1577
+
811
1578
  let mut index_by_id = HashMap::new();
812
1579
  for (index, step) in doc.steps.iter().enumerate() {
813
1580
  if step.id.trim().is_empty() || step.step_type.trim().is_empty() {