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/README.md +4 -1
- package/index.d.ts +4 -1
- package/index.js +443 -4
- package/package.json +1 -1
- package/pkg/simple_agents_wasm.js +9 -1
- package/pkg/simple_agents_wasm_bg.wasm +0 -0
- package/rust/Cargo.toml +1 -1
- package/rust/src/lib.rs +791 -24
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
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
.
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1169
|
+
consume_sse_blocks(&response, |block| {
|
|
679
1170
|
let Some(data) = parse_sse_data_line(&block) else {
|
|
680
|
-
|
|
1171
|
+
return Ok(true);
|
|
681
1172
|
};
|
|
682
1173
|
if data == "[DONE]" {
|
|
683
|
-
|
|
1174
|
+
return Ok(false);
|
|
684
1175
|
}
|
|
685
1176
|
|
|
686
1177
|
let Ok(chunk) = serde_json::from_str::<JsonValue>(&data) else {
|
|
687
|
-
|
|
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
|
|
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() {
|