simple-agents-wasm 0.2.31 → 0.2.33
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/index.d.ts +1 -0
- package/index.js +314 -228
- package/package.json +2 -1
- package/pkg/simple_agents_wasm.d.ts +3 -3
- package/pkg/simple_agents_wasm.js +6 -6
- package/pkg/simple_agents_wasm_bg.wasm +0 -0
- package/pkg/simple_agents_wasm_bg.wasm.d.ts +3 -3
- package/runtime/errors.js +7 -0
- package/runtime/rust-runtime.js +18 -0
- package/runtime/stream.js +136 -0
- package/rust/Cargo.toml +1 -1
- package/rust/src/lib.rs +202 -7
package/index.d.ts
CHANGED
package/index.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { parse as parseYaml } from "yaml";
|
|
2
|
+
import { configError, runtimeError } from "./runtime/errors.js";
|
|
3
|
+
import {
|
|
4
|
+
applyDeltaToAggregate,
|
|
5
|
+
createStreamAggregator,
|
|
6
|
+
createStreamEventBridge,
|
|
7
|
+
iterateSse,
|
|
8
|
+
parseSseEventBlock
|
|
9
|
+
} from "./runtime/stream.js";
|
|
10
|
+
import { loadRustModule } from "./runtime/rust-runtime.js";
|
|
2
11
|
|
|
3
12
|
const DEFAULT_BASE_URLS = {
|
|
4
13
|
openai: "https://api.openai.com/v1",
|
|
5
14
|
openrouter: "https://openrouter.ai/api/v1"
|
|
6
15
|
};
|
|
7
16
|
|
|
8
|
-
function configError(message) {
|
|
9
|
-
return new Error(`simple-agents-wasm config error: ${message}`);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function runtimeError(message) {
|
|
13
|
-
return new Error(`simple-agents-wasm runtime error: ${message}`);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
17
|
function toMessages(promptOrMessages) {
|
|
17
18
|
if (typeof promptOrMessages === "string") {
|
|
18
19
|
const content = promptOrMessages.trim();
|
|
@@ -62,10 +63,100 @@ function toToolCalls(toolCalls) {
|
|
|
62
63
|
}));
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
|
|
67
|
+
function assertWorkflowResultShape(result) {
|
|
68
|
+
if (result === null || typeof result !== "object") {
|
|
69
|
+
throw runtimeError(
|
|
70
|
+
"workflow result contract mismatch: expected an object with workflow_id and outputs"
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!("workflow_id" in result) || !("outputs" in result)) {
|
|
75
|
+
throw runtimeError(
|
|
76
|
+
"workflow result contract mismatch: expected keys 'workflow_id' and 'outputs'"
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeWorkflowResult(result) {
|
|
84
|
+
if (result === null || typeof result !== "object") {
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
if ("workflow_id" in result && "outputs" in result) {
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
if (!("context" in result) || !result.context || typeof result.context !== "object") {
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const context = result.context;
|
|
95
|
+
const nodeOutputs =
|
|
96
|
+
context && typeof context === "object" && context.nodes && typeof context.nodes === "object"
|
|
97
|
+
? context.nodes
|
|
98
|
+
: context;
|
|
99
|
+
const trace = Array.isArray(result.events)
|
|
100
|
+
? result.events
|
|
101
|
+
.filter((event) => event && event.status === "completed" && typeof event.stepId === "string")
|
|
102
|
+
.map((event) => event.stepId)
|
|
103
|
+
: [];
|
|
104
|
+
const terminalNode = trace.at(-1) ?? "";
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
workflow_id: typeof result.workflow_id === "string" ? result.workflow_id : "wasm_workflow",
|
|
108
|
+
entry_node: typeof result.entry_node === "string" ? result.entry_node : trace[0] ?? "",
|
|
109
|
+
email_text: typeof context?.input?.email_text === "string" ? context.input.email_text : "",
|
|
110
|
+
trace,
|
|
111
|
+
outputs: nodeOutputs,
|
|
112
|
+
terminal_node: typeof result.terminal_node === "string" ? result.terminal_node : terminalNode,
|
|
113
|
+
terminal_output: result.output,
|
|
114
|
+
events: Array.isArray(result.events) ? result.events : [],
|
|
115
|
+
status: typeof result.status === "string" ? result.status : "ok"
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
65
119
|
function normalizeBaseUrl(baseUrl) {
|
|
66
120
|
return baseUrl.replace(/\/$/, "");
|
|
67
121
|
}
|
|
68
122
|
|
|
123
|
+
function finiteNumberOrNull(value) {
|
|
124
|
+
return Number.isFinite(value) ? value : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildStepDetail(step) {
|
|
128
|
+
return {
|
|
129
|
+
node_id: step.nodeId,
|
|
130
|
+
node_kind: step.nodeKind,
|
|
131
|
+
model_name: step.modelName ?? null,
|
|
132
|
+
elapsed_ms: step.elapsedMs,
|
|
133
|
+
prompt_tokens: finiteNumberOrNull(step.promptTokens),
|
|
134
|
+
completion_tokens: finiteNumberOrNull(step.completionTokens),
|
|
135
|
+
total_tokens: finiteNumberOrNull(step.totalTokens),
|
|
136
|
+
reasoning_tokens: 0,
|
|
137
|
+
tokens_per_second: finiteNumberOrNull(step.tokensPerSecond)
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildWorkflowNerdstats(summary) {
|
|
142
|
+
return {
|
|
143
|
+
workflow_id: summary.workflowId,
|
|
144
|
+
terminal_node: summary.terminalNode,
|
|
145
|
+
total_elapsed_ms: summary.totalElapsedMs,
|
|
146
|
+
ttft_ms: summary.ttftMs,
|
|
147
|
+
step_details: summary.stepDetails,
|
|
148
|
+
total_input_tokens: summary.totalInputTokens,
|
|
149
|
+
total_output_tokens: summary.totalOutputTokens,
|
|
150
|
+
total_tokens: summary.totalTokens,
|
|
151
|
+
total_reasoning_tokens: summary.totalReasoningTokens,
|
|
152
|
+
tokens_per_second: summary.tokensPerSecond,
|
|
153
|
+
trace_id: summary.traceId,
|
|
154
|
+
token_metrics_available: summary.tokenMetricsAvailable,
|
|
155
|
+
token_metrics_source: summary.tokenMetricsSource,
|
|
156
|
+
llm_nodes_without_usage: summary.llmNodesWithoutUsage
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
69
160
|
function interpolate(value, context) {
|
|
70
161
|
if (typeof value === "string") {
|
|
71
162
|
return value.replace(/{{\s*([^}]+)\s*}}/g, (_, key) => {
|
|
@@ -440,10 +531,6 @@ function llmOutputSchema(node) {
|
|
|
440
531
|
};
|
|
441
532
|
}
|
|
442
533
|
|
|
443
|
-
function normalizeSseChunk(chunk) {
|
|
444
|
-
return chunk.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
445
|
-
}
|
|
446
|
-
|
|
447
534
|
function evaluateSwitchCondition(condition, context) {
|
|
448
535
|
if (typeof condition !== "string") {
|
|
449
536
|
return false;
|
|
@@ -464,66 +551,6 @@ function evaluateSwitchCondition(condition, context) {
|
|
|
464
551
|
return false;
|
|
465
552
|
}
|
|
466
553
|
|
|
467
|
-
function parseSseEventBlock(block) {
|
|
468
|
-
const lines = block.split("\n");
|
|
469
|
-
const dataLines = [];
|
|
470
|
-
for (const line of lines) {
|
|
471
|
-
if (line.startsWith("data:")) {
|
|
472
|
-
dataLines.push(line.slice(5).trimStart());
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
if (dataLines.length === 0) {
|
|
477
|
-
return null;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const payload = dataLines.join("\n");
|
|
481
|
-
if (payload === "[DONE]") {
|
|
482
|
-
return { done: true };
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
try {
|
|
486
|
-
return { done: false, json: JSON.parse(payload), raw: payload };
|
|
487
|
-
} catch {
|
|
488
|
-
return { done: false, raw: payload };
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
async function* iterateSse(response) {
|
|
493
|
-
if (!response.body) {
|
|
494
|
-
throw runtimeError("stream response had no body");
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
const reader = response.body.getReader();
|
|
498
|
-
const decoder = new TextDecoder();
|
|
499
|
-
let buffer = "";
|
|
500
|
-
|
|
501
|
-
while (true) {
|
|
502
|
-
const { value, done } = await reader.read();
|
|
503
|
-
if (done) {
|
|
504
|
-
break;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
buffer += normalizeSseChunk(decoder.decode(value, { stream: true }));
|
|
508
|
-
let delimiterIndex = buffer.indexOf("\n\n");
|
|
509
|
-
while (delimiterIndex !== -1) {
|
|
510
|
-
const block = buffer.slice(0, delimiterIndex).trim();
|
|
511
|
-
buffer = buffer.slice(delimiterIndex + 2);
|
|
512
|
-
if (block.length > 0) {
|
|
513
|
-
yield block;
|
|
514
|
-
}
|
|
515
|
-
delimiterIndex = buffer.indexOf("\n\n");
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
buffer += normalizeSseChunk(decoder.decode());
|
|
520
|
-
|
|
521
|
-
const trailing = buffer.trim();
|
|
522
|
-
if (trailing.length > 0) {
|
|
523
|
-
yield trailing;
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
554
|
class BrowserJsClient {
|
|
528
555
|
constructor(provider, config) {
|
|
529
556
|
if (provider !== "openai" && provider !== "openrouter") {
|
|
@@ -613,63 +640,17 @@ class BrowserJsClient {
|
|
|
613
640
|
throw configError("onChunk callback is required");
|
|
614
641
|
}
|
|
615
642
|
|
|
616
|
-
let aggregate = "";
|
|
617
|
-
let finalId = "";
|
|
618
|
-
let finalModel = model;
|
|
619
|
-
let finalFinishReason;
|
|
620
643
|
const started = performance.now();
|
|
644
|
+
const streamBridge = createStreamEventBridge(model, onChunk);
|
|
621
645
|
|
|
622
646
|
const result = await this.streamEvents(
|
|
623
647
|
model,
|
|
624
648
|
promptOrMessages,
|
|
625
|
-
(event) =>
|
|
626
|
-
if (event.eventType === "delta") {
|
|
627
|
-
const delta = event.delta;
|
|
628
|
-
if (!delta) {
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
if (delta.id && finalId.length === 0) {
|
|
633
|
-
finalId = delta.id;
|
|
634
|
-
}
|
|
635
|
-
if (delta.model) {
|
|
636
|
-
finalModel = delta.model;
|
|
637
|
-
}
|
|
638
|
-
if (delta.content) {
|
|
639
|
-
aggregate += delta.content;
|
|
640
|
-
}
|
|
641
|
-
if (delta.finishReason) {
|
|
642
|
-
finalFinishReason = delta.finishReason;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
onChunk({
|
|
646
|
-
id: delta.id,
|
|
647
|
-
model: delta.model,
|
|
648
|
-
content: delta.content,
|
|
649
|
-
finishReason: delta.finishReason,
|
|
650
|
-
raw: delta.raw
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
if (event.eventType === "error") {
|
|
655
|
-
onChunk({
|
|
656
|
-
id: finalId || "error",
|
|
657
|
-
model: finalModel,
|
|
658
|
-
error: event.error?.message ?? "stream error"
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
|
-
},
|
|
649
|
+
(event) => streamBridge.onEvent(event),
|
|
662
650
|
options
|
|
663
651
|
);
|
|
664
652
|
|
|
665
|
-
return
|
|
666
|
-
...result,
|
|
667
|
-
id: result.id || finalId,
|
|
668
|
-
model: result.model || finalModel,
|
|
669
|
-
content: result.content ?? aggregate,
|
|
670
|
-
finishReason: result.finishReason ?? finalFinishReason,
|
|
671
|
-
latencyMs: Math.max(0, Math.round(performance.now() - started))
|
|
672
|
-
};
|
|
653
|
+
return streamBridge.mergeResult(result, started);
|
|
673
654
|
}
|
|
674
655
|
|
|
675
656
|
async streamEvents(model, promptOrMessages, onEvent, options = {}) {
|
|
@@ -694,7 +675,10 @@ class BrowserJsClient {
|
|
|
694
675
|
max_tokens: options.maxTokens,
|
|
695
676
|
temperature: options.temperature,
|
|
696
677
|
top_p: options.topP,
|
|
697
|
-
stream: true
|
|
678
|
+
stream: true,
|
|
679
|
+
stream_options: {
|
|
680
|
+
include_usage: true
|
|
681
|
+
}
|
|
698
682
|
})
|
|
699
683
|
});
|
|
700
684
|
|
|
@@ -707,10 +691,13 @@ class BrowserJsClient {
|
|
|
707
691
|
}
|
|
708
692
|
|
|
709
693
|
const started = performance.now();
|
|
710
|
-
|
|
711
|
-
let
|
|
712
|
-
|
|
713
|
-
|
|
694
|
+
const aggregateState = createStreamAggregator(model);
|
|
695
|
+
let usage = {
|
|
696
|
+
promptTokens: 0,
|
|
697
|
+
completionTokens: 0,
|
|
698
|
+
totalTokens: 0
|
|
699
|
+
};
|
|
700
|
+
let usageAvailable = false;
|
|
714
701
|
|
|
715
702
|
try {
|
|
716
703
|
for await (const block of iterateSse(response)) {
|
|
@@ -739,17 +726,10 @@ class BrowserJsClient {
|
|
|
739
726
|
raw: parsed.raw
|
|
740
727
|
};
|
|
741
728
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
responseModel = delta.model;
|
|
747
|
-
}
|
|
748
|
-
if (delta.content) {
|
|
749
|
-
aggregate += delta.content;
|
|
750
|
-
}
|
|
751
|
-
if (delta.finishReason) {
|
|
752
|
-
finishReason = delta.finishReason;
|
|
729
|
+
applyDeltaToAggregate(aggregateState, delta);
|
|
730
|
+
if (chunk?.usage && typeof chunk.usage === "object") {
|
|
731
|
+
usage = toUsage(chunk.usage);
|
|
732
|
+
usageAvailable = true;
|
|
753
733
|
}
|
|
754
734
|
|
|
755
735
|
onEvent({ eventType: "delta", delta });
|
|
@@ -765,17 +745,15 @@ class BrowserJsClient {
|
|
|
765
745
|
const latencyMs = Math.max(0, Math.round(performance.now() - started));
|
|
766
746
|
|
|
767
747
|
return {
|
|
768
|
-
id: responseId,
|
|
769
|
-
model: responseModel,
|
|
748
|
+
id: aggregateState.responseId,
|
|
749
|
+
model: aggregateState.responseModel,
|
|
770
750
|
role: "assistant",
|
|
771
|
-
content: aggregate,
|
|
772
|
-
finishReason,
|
|
751
|
+
content: aggregateState.aggregate,
|
|
752
|
+
finishReason: aggregateState.finishReason,
|
|
773
753
|
usage: {
|
|
774
|
-
|
|
775
|
-
completionTokens: 0,
|
|
776
|
-
totalTokens: 0
|
|
754
|
+
...usage
|
|
777
755
|
},
|
|
778
|
-
usageAvailable
|
|
756
|
+
usageAvailable,
|
|
779
757
|
latencyMs,
|
|
780
758
|
raw: undefined,
|
|
781
759
|
healed: undefined,
|
|
@@ -826,9 +804,17 @@ class BrowserJsClient {
|
|
|
826
804
|
nodes: {}
|
|
827
805
|
};
|
|
828
806
|
|
|
807
|
+
const workflowStarted = performance.now();
|
|
808
|
+
let workflowTtftMs = 0;
|
|
829
809
|
let pointer = doc.entry_node;
|
|
830
810
|
let output;
|
|
831
811
|
let iterations = 0;
|
|
812
|
+
const stepDetails = [];
|
|
813
|
+
let totalInputTokens = 0;
|
|
814
|
+
let totalOutputTokens = 0;
|
|
815
|
+
let totalTokens = 0;
|
|
816
|
+
const totalReasoningTokens = 0;
|
|
817
|
+
const llmNodesWithoutUsage = [];
|
|
832
818
|
|
|
833
819
|
while (typeof pointer === "string" && pointer.length > 0) {
|
|
834
820
|
iterations += 1;
|
|
@@ -843,6 +829,11 @@ class BrowserJsClient {
|
|
|
843
829
|
|
|
844
830
|
const nodeType = node.node_type ?? {};
|
|
845
831
|
const nodeTypeName = Object.keys(nodeType)[0] ?? "unknown";
|
|
832
|
+
const nodeStarted = performance.now();
|
|
833
|
+
let stepModelName;
|
|
834
|
+
let stepPromptTokens;
|
|
835
|
+
let stepCompletionTokens;
|
|
836
|
+
let stepTotalTokens;
|
|
846
837
|
events.push({ stepId: node.id, stepType: nodeTypeName, status: "started" });
|
|
847
838
|
|
|
848
839
|
if (nodeType.llm_call) {
|
|
@@ -874,10 +865,57 @@ class BrowserJsClient {
|
|
|
874
865
|
promptOrMessages = history;
|
|
875
866
|
}
|
|
876
867
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
868
|
+
let rawContent = "";
|
|
869
|
+
let completion;
|
|
870
|
+
if (llm.stream === true) {
|
|
871
|
+
completion = await this.streamEvents(
|
|
872
|
+
model,
|
|
873
|
+
promptOrMessages,
|
|
874
|
+
(event) => {
|
|
875
|
+
if (event && event.eventType === "delta" && typeof event.delta?.content === "string") {
|
|
876
|
+
if (workflowTtftMs === 0) {
|
|
877
|
+
const measured = Math.max(0, Math.round(performance.now() - workflowStarted));
|
|
878
|
+
workflowTtftMs = measured === 0 ? 1 : measured;
|
|
879
|
+
}
|
|
880
|
+
rawContent += event.delta.content;
|
|
881
|
+
if (typeof workflowOptions?.onEvent === "function") {
|
|
882
|
+
workflowOptions.onEvent({
|
|
883
|
+
eventType: "node_stream_delta",
|
|
884
|
+
nodeId: node.id,
|
|
885
|
+
delta: event.delta.content,
|
|
886
|
+
model: event.delta.model ?? model
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
},
|
|
891
|
+
{
|
|
892
|
+
temperature: llm.temperature
|
|
893
|
+
}
|
|
894
|
+
);
|
|
895
|
+
rawContent = completion.content ?? rawContent;
|
|
896
|
+
} else {
|
|
897
|
+
completion = await this.complete(model, promptOrMessages, {
|
|
898
|
+
temperature: llm.temperature
|
|
899
|
+
});
|
|
900
|
+
rawContent = completion.content ?? "";
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
stepModelName = completion?.model ?? model;
|
|
904
|
+
if (completion?.usageAvailable === true && completion?.usage && typeof completion.usage === "object") {
|
|
905
|
+
const usage = completion.usage;
|
|
906
|
+
stepPromptTokens = Number.isFinite(usage.promptTokens) ? usage.promptTokens : 0;
|
|
907
|
+
stepCompletionTokens = Number.isFinite(usage.completionTokens)
|
|
908
|
+
? usage.completionTokens
|
|
909
|
+
: 0;
|
|
910
|
+
stepTotalTokens = Number.isFinite(usage.totalTokens) ? usage.totalTokens : 0;
|
|
911
|
+
totalInputTokens += stepPromptTokens;
|
|
912
|
+
totalOutputTokens += stepCompletionTokens;
|
|
913
|
+
totalTokens += stepTotalTokens;
|
|
914
|
+
} else {
|
|
915
|
+
llmNodesWithoutUsage.push(node.id);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const parsedOutput = maybeParseJson(rawContent);
|
|
881
919
|
const validationError = schemaValidationError(llmOutputSchema(node), parsedOutput);
|
|
882
920
|
if (validationError !== null) {
|
|
883
921
|
throw runtimeError(
|
|
@@ -886,7 +924,7 @@ class BrowserJsClient {
|
|
|
886
924
|
}
|
|
887
925
|
graphContext.nodes[node.id] = {
|
|
888
926
|
output: parsedOutput,
|
|
889
|
-
raw:
|
|
927
|
+
raw: rawContent
|
|
890
928
|
};
|
|
891
929
|
output = parsedOutput;
|
|
892
930
|
|
|
@@ -901,15 +939,22 @@ class BrowserJsClient {
|
|
|
901
939
|
pointer = matched?.target ?? switchSpec.default ?? "";
|
|
902
940
|
} else if (nodeType.custom_worker) {
|
|
903
941
|
const handler = nodeType.custom_worker.handler ?? "custom_worker";
|
|
904
|
-
const
|
|
942
|
+
const handlerFile = nodeType.custom_worker.handler_file;
|
|
943
|
+
const lookupKey =
|
|
944
|
+
typeof handlerFile === "string" && handlerFile.length > 0
|
|
945
|
+
? `${handlerFile}#${handler}`
|
|
946
|
+
: handler;
|
|
947
|
+
const fn = functions[lookupKey];
|
|
905
948
|
if (typeof fn !== "function") {
|
|
906
949
|
throw runtimeError(
|
|
907
|
-
`custom_worker node '${node.id}' requires workflowOptions.functions['${
|
|
950
|
+
`custom_worker node '${node.id}' requires workflowOptions.functions['${lookupKey}']`
|
|
908
951
|
);
|
|
909
952
|
}
|
|
910
953
|
const workerOutput = await fn(
|
|
911
954
|
{
|
|
912
955
|
handler,
|
|
956
|
+
handler_file: handlerFile,
|
|
957
|
+
handler_lookup_key: lookupKey,
|
|
913
958
|
payload: interpolatePathValue(node.config?.payload ?? null, graphContext),
|
|
914
959
|
nodeId: node.id
|
|
915
960
|
},
|
|
@@ -926,13 +971,92 @@ class BrowserJsClient {
|
|
|
926
971
|
}
|
|
927
972
|
|
|
928
973
|
events.push({ stepId: node.id, stepType: nodeTypeName, status: "completed" });
|
|
974
|
+
const elapsedMs = Math.max(0, Math.round(performance.now() - nodeStarted));
|
|
975
|
+
const stepTokensPerSecond =
|
|
976
|
+
Number.isFinite(stepCompletionTokens) && elapsedMs > 0
|
|
977
|
+
? Math.round((stepCompletionTokens / (elapsedMs / 1000)) * 100) / 100
|
|
978
|
+
: null;
|
|
979
|
+
stepDetails.push(
|
|
980
|
+
buildStepDetail({
|
|
981
|
+
nodeId: node.id,
|
|
982
|
+
nodeKind: nodeTypeName,
|
|
983
|
+
modelName: stepModelName,
|
|
984
|
+
elapsedMs,
|
|
985
|
+
promptTokens: stepPromptTokens,
|
|
986
|
+
completionTokens: stepCompletionTokens,
|
|
987
|
+
totalTokens: stepTotalTokens,
|
|
988
|
+
tokensPerSecond: stepTokensPerSecond
|
|
989
|
+
})
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const trace = events
|
|
994
|
+
.filter((event) => event && event.status === "completed")
|
|
995
|
+
.map((event) => event.stepId);
|
|
996
|
+
const terminalNode = trace.at(-1) ?? "";
|
|
997
|
+
const totalElapsedMs = Math.max(0, Math.round(performance.now() - workflowStarted));
|
|
998
|
+
const tokenMetricsAvailable = llmNodesWithoutUsage.length === 0;
|
|
999
|
+
const overallTokensPerSecond =
|
|
1000
|
+
totalElapsedMs > 0 ? Math.round((totalOutputTokens / (totalElapsedMs / 1000)) * 100) / 100 : 0;
|
|
1001
|
+
const workflowId =
|
|
1002
|
+
typeof doc.id === "string" && doc.id.length > 0 ? doc.id : "browser_js_workflow";
|
|
1003
|
+
const nerdstats = buildWorkflowNerdstats({
|
|
1004
|
+
workflowId,
|
|
1005
|
+
terminalNode,
|
|
1006
|
+
totalElapsedMs,
|
|
1007
|
+
ttftMs: workflowTtftMs,
|
|
1008
|
+
stepDetails,
|
|
1009
|
+
totalInputTokens,
|
|
1010
|
+
totalOutputTokens,
|
|
1011
|
+
totalTokens,
|
|
1012
|
+
totalReasoningTokens,
|
|
1013
|
+
tokensPerSecond: overallTokensPerSecond,
|
|
1014
|
+
traceId: "",
|
|
1015
|
+
tokenMetricsAvailable,
|
|
1016
|
+
tokenMetricsSource: tokenMetricsAvailable ? "provider_usage" : "unavailable",
|
|
1017
|
+
llmNodesWithoutUsage
|
|
1018
|
+
});
|
|
1019
|
+
if (typeof workflowOptions?.onEvent === "function") {
|
|
1020
|
+
workflowOptions.onEvent({
|
|
1021
|
+
event_type: "workflow_completed",
|
|
1022
|
+
metadata: {
|
|
1023
|
+
nerdstats
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const outputs = {};
|
|
1029
|
+
for (const [nodeId, nodeValue] of Object.entries(graphContext.nodes ?? {})) {
|
|
1030
|
+
if (nodeValue && typeof nodeValue === "object" && "output" in nodeValue) {
|
|
1031
|
+
outputs[nodeId] = nodeValue.output;
|
|
1032
|
+
} else {
|
|
1033
|
+
outputs[nodeId] = nodeValue;
|
|
1034
|
+
}
|
|
929
1035
|
}
|
|
930
1036
|
|
|
931
1037
|
return {
|
|
932
|
-
|
|
1038
|
+
workflow_id: workflowId,
|
|
1039
|
+
entry_node: doc.entry_node,
|
|
1040
|
+
email_text: typeof graphContext.input?.email_text === "string" ? graphContext.input.email_text : "",
|
|
1041
|
+
trace,
|
|
1042
|
+
outputs,
|
|
1043
|
+
terminal_node: terminalNode,
|
|
1044
|
+
terminal_output: output,
|
|
1045
|
+
step_timings: stepDetails,
|
|
1046
|
+
total_elapsed_ms: totalElapsedMs,
|
|
1047
|
+
ttft_ms: workflowTtftMs,
|
|
1048
|
+
total_input_tokens: totalInputTokens,
|
|
1049
|
+
total_output_tokens: totalOutputTokens,
|
|
1050
|
+
total_tokens: totalTokens,
|
|
1051
|
+
total_reasoning_tokens: totalReasoningTokens,
|
|
1052
|
+
tokens_per_second: overallTokensPerSecond,
|
|
1053
|
+
trace_id: "",
|
|
1054
|
+
metadata: {
|
|
1055
|
+
nerdstats
|
|
1056
|
+
},
|
|
1057
|
+
events,
|
|
933
1058
|
context: graphContext,
|
|
934
|
-
|
|
935
|
-
events
|
|
1059
|
+
status: "ok"
|
|
936
1060
|
};
|
|
937
1061
|
}
|
|
938
1062
|
|
|
@@ -1021,10 +1145,22 @@ class BrowserJsClient {
|
|
|
1021
1145
|
}
|
|
1022
1146
|
|
|
1023
1147
|
return {
|
|
1024
|
-
|
|
1148
|
+
workflow_id:
|
|
1149
|
+
typeof doc.id === "string" && doc.id.length > 0 ? doc.id : "browser_js_workflow",
|
|
1150
|
+
entry_node: typeof doc.steps?.[0]?.id === "string" ? doc.steps[0].id : "",
|
|
1151
|
+
email_text: typeof workflowInput?.email_text === "string" ? workflowInput.email_text : "",
|
|
1152
|
+
trace: events
|
|
1153
|
+
.filter((event) => event && event.status === "completed")
|
|
1154
|
+
.map((event) => event.stepId),
|
|
1155
|
+
outputs: { ...context },
|
|
1156
|
+
terminal_node: events
|
|
1157
|
+
.filter((event) => event && event.status === "completed")
|
|
1158
|
+
.map((event) => event.stepId)
|
|
1159
|
+
.at(-1) ?? "",
|
|
1160
|
+
terminal_output: output,
|
|
1161
|
+
events,
|
|
1025
1162
|
context,
|
|
1026
|
-
|
|
1027
|
-
events
|
|
1163
|
+
status: "ok"
|
|
1028
1164
|
};
|
|
1029
1165
|
}
|
|
1030
1166
|
|
|
@@ -1035,25 +1171,6 @@ class BrowserJsClient {
|
|
|
1035
1171
|
}
|
|
1036
1172
|
}
|
|
1037
1173
|
|
|
1038
|
-
let rustModulePromise;
|
|
1039
|
-
|
|
1040
|
-
async function loadRustModule() {
|
|
1041
|
-
if (!rustModulePromise) {
|
|
1042
|
-
rustModulePromise = (async () => {
|
|
1043
|
-
try {
|
|
1044
|
-
const moduleValue = await import("./pkg/simple_agents_wasm.js");
|
|
1045
|
-
const wasmUrl = new URL("./pkg/simple_agents_wasm_bg.wasm", import.meta.url);
|
|
1046
|
-
await moduleValue.default(wasmUrl);
|
|
1047
|
-
return moduleValue;
|
|
1048
|
-
} catch {
|
|
1049
|
-
return null;
|
|
1050
|
-
}
|
|
1051
|
-
})();
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
return rustModulePromise;
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
1174
|
export class Client {
|
|
1058
1175
|
constructor(provider, config) {
|
|
1059
1176
|
this.fallbackClient = new BrowserJsClient(provider, config);
|
|
@@ -1108,56 +1225,17 @@ export class Client {
|
|
|
1108
1225
|
async stream(model, promptOrMessages, onChunk, options = {}) {
|
|
1109
1226
|
const rust = await this.ensureBackend();
|
|
1110
1227
|
if (rust) {
|
|
1111
|
-
let aggregate = "";
|
|
1112
|
-
let finalId = "";
|
|
1113
|
-
let finalModel = model;
|
|
1114
|
-
let finalFinishReason;
|
|
1115
1228
|
const started = performance.now();
|
|
1229
|
+
const streamBridge = createStreamEventBridge(model, onChunk);
|
|
1116
1230
|
|
|
1117
|
-
const result = await rust.streamEvents(
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
if (!finalId && delta.id) {
|
|
1124
|
-
finalId = delta.id;
|
|
1125
|
-
}
|
|
1126
|
-
if (delta.model) {
|
|
1127
|
-
finalModel = delta.model;
|
|
1128
|
-
}
|
|
1129
|
-
if (delta.content) {
|
|
1130
|
-
aggregate += delta.content;
|
|
1131
|
-
}
|
|
1132
|
-
if (delta.finishReason) {
|
|
1133
|
-
finalFinishReason = delta.finishReason;
|
|
1134
|
-
}
|
|
1135
|
-
onChunk({
|
|
1136
|
-
id: delta.id,
|
|
1137
|
-
model: delta.model,
|
|
1138
|
-
content: delta.content,
|
|
1139
|
-
finishReason: delta.finishReason,
|
|
1140
|
-
raw: delta.raw
|
|
1141
|
-
});
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
if (event.eventType === "error") {
|
|
1145
|
-
onChunk({
|
|
1146
|
-
id: finalId || "error",
|
|
1147
|
-
model: finalModel,
|
|
1148
|
-
error: event.error?.message ?? "stream error"
|
|
1149
|
-
});
|
|
1150
|
-
}
|
|
1151
|
-
}, options);
|
|
1231
|
+
const result = await rust.streamEvents(
|
|
1232
|
+
model,
|
|
1233
|
+
promptOrMessages,
|
|
1234
|
+
(event) => streamBridge.onEvent(event),
|
|
1235
|
+
options
|
|
1236
|
+
);
|
|
1152
1237
|
|
|
1153
|
-
return
|
|
1154
|
-
...result,
|
|
1155
|
-
id: result.id || finalId,
|
|
1156
|
-
model: result.model || finalModel,
|
|
1157
|
-
content: result.content ?? aggregate,
|
|
1158
|
-
finishReason: result.finishReason ?? finalFinishReason,
|
|
1159
|
-
latencyMs: Math.max(0, Math.round(performance.now() - started))
|
|
1160
|
-
};
|
|
1238
|
+
return streamBridge.mergeResult(result, started);
|
|
1161
1239
|
}
|
|
1162
1240
|
|
|
1163
1241
|
return this.fallbackClient.stream(model, promptOrMessages, onChunk, options);
|
|
@@ -1174,17 +1252,25 @@ export class Client {
|
|
|
1174
1252
|
async runWorkflowYamlString(yamlText, workflowInput, workflowOptions) {
|
|
1175
1253
|
const rust = await this.ensureBackend();
|
|
1176
1254
|
if (rust) {
|
|
1177
|
-
|
|
1255
|
+
const result = await rust.runWorkflowYamlString(yamlText, workflowInput, workflowOptions);
|
|
1256
|
+
return assertWorkflowResultShape(normalizeWorkflowResult(result));
|
|
1178
1257
|
}
|
|
1179
|
-
|
|
1258
|
+
const result = await this.fallbackClient.runWorkflowYamlString(
|
|
1259
|
+
yamlText,
|
|
1260
|
+
workflowInput,
|
|
1261
|
+
workflowOptions
|
|
1262
|
+
);
|
|
1263
|
+
return assertWorkflowResultShape(normalizeWorkflowResult(result));
|
|
1180
1264
|
}
|
|
1181
1265
|
|
|
1182
1266
|
async runWorkflowYaml(workflowPath, workflowInput) {
|
|
1183
1267
|
const rust = await this.ensureBackend();
|
|
1184
1268
|
if (rust) {
|
|
1185
|
-
|
|
1269
|
+
const result = await rust.runWorkflowYaml(workflowPath, workflowInput);
|
|
1270
|
+
return assertWorkflowResultShape(normalizeWorkflowResult(result));
|
|
1186
1271
|
}
|
|
1187
|
-
|
|
1272
|
+
const result = await this.fallbackClient.runWorkflowYaml(workflowPath, workflowInput);
|
|
1273
|
+
return assertWorkflowResultShape(normalizeWorkflowResult(result));
|
|
1188
1274
|
}
|
|
1189
1275
|
}
|
|
1190
1276
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simple-agents-wasm",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.33",
|
|
4
4
|
"description": "Browser-compatible SimpleAgents client for OpenAI-compatible providers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"index.js",
|
|
18
18
|
"index.d.ts",
|
|
19
|
+
"runtime",
|
|
19
20
|
"README.md",
|
|
20
21
|
"pkg",
|
|
21
22
|
"rust/Cargo.toml",
|
|
@@ -27,9 +27,9 @@ export interface InitOutput {
|
|
|
27
27
|
readonly wasmclient_runWorkflowYaml: (a: number, b: number, c: number, d: any) => [number, number, number];
|
|
28
28
|
readonly wasmclient_runWorkflowYamlString: (a: number, b: number, c: number, d: any, e: number) => any;
|
|
29
29
|
readonly wasmclient_streamEvents: (a: number, b: number, c: number, d: any, e: any, f: number) => any;
|
|
30
|
-
readonly
|
|
31
|
-
readonly
|
|
32
|
-
readonly
|
|
30
|
+
readonly wasm_bindgen__closure__destroy__haab24713ba6d07cd: (a: number, b: number) => void;
|
|
31
|
+
readonly wasm_bindgen__convert__closures_____invoke__h0653cfab9850c2f3: (a: number, b: number, c: any) => [number, number];
|
|
32
|
+
readonly wasm_bindgen__convert__closures_____invoke__h133df7605c346924: (a: number, b: number, c: any, d: any) => void;
|
|
33
33
|
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
|
34
34
|
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
|
35
35
|
readonly __wbindgen_exn_store: (a: number) => void;
|
|
@@ -314,7 +314,7 @@ function __wbg_get_imports() {
|
|
|
314
314
|
const a = state0.a;
|
|
315
315
|
state0.a = 0;
|
|
316
316
|
try {
|
|
317
|
-
return
|
|
317
|
+
return wasm_bindgen__convert__closures_____invoke__h133df7605c346924(a, state0.b, arg0, arg1);
|
|
318
318
|
} finally {
|
|
319
319
|
state0.a = a;
|
|
320
320
|
}
|
|
@@ -395,7 +395,7 @@ function __wbg_get_imports() {
|
|
|
395
395
|
},
|
|
396
396
|
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
|
397
397
|
// Cast intrinsic for `Closure(Closure { dtor_idx: 106, function: Function { arguments: [Externref], shim_idx: 107, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
|
398
|
-
const ret = makeMutClosure(arg0, arg1, wasm.
|
|
398
|
+
const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__haab24713ba6d07cd, wasm_bindgen__convert__closures_____invoke__h0653cfab9850c2f3);
|
|
399
399
|
return ret;
|
|
400
400
|
},
|
|
401
401
|
__wbindgen_cast_0000000000000002: function(arg0) {
|
|
@@ -434,15 +434,15 @@ function __wbg_get_imports() {
|
|
|
434
434
|
};
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
-
function
|
|
438
|
-
const ret = wasm.
|
|
437
|
+
function wasm_bindgen__convert__closures_____invoke__h0653cfab9850c2f3(arg0, arg1, arg2) {
|
|
438
|
+
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h0653cfab9850c2f3(arg0, arg1, arg2);
|
|
439
439
|
if (ret[1]) {
|
|
440
440
|
throw takeFromExternrefTable0(ret[0]);
|
|
441
441
|
}
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
-
function
|
|
445
|
-
wasm.
|
|
444
|
+
function wasm_bindgen__convert__closures_____invoke__h133df7605c346924(arg0, arg1, arg2, arg3) {
|
|
445
|
+
wasm.wasm_bindgen__convert__closures_____invoke__h133df7605c346924(arg0, arg1, arg2, arg3);
|
|
446
446
|
}
|
|
447
447
|
|
|
448
448
|
const WasmClientFinalization = (typeof FinalizationRegistry === 'undefined')
|
|
Binary file
|
|
@@ -9,9 +9,9 @@ export const wasmclient_new: (a: number, b: number, c: any) => [number, number,
|
|
|
9
9
|
export const wasmclient_runWorkflowYaml: (a: number, b: number, c: number, d: any) => [number, number, number];
|
|
10
10
|
export const wasmclient_runWorkflowYamlString: (a: number, b: number, c: number, d: any, e: number) => any;
|
|
11
11
|
export const wasmclient_streamEvents: (a: number, b: number, c: number, d: any, e: any, f: number) => any;
|
|
12
|
-
export const
|
|
13
|
-
export const
|
|
14
|
-
export const
|
|
12
|
+
export const wasm_bindgen__closure__destroy__haab24713ba6d07cd: (a: number, b: number) => void;
|
|
13
|
+
export const wasm_bindgen__convert__closures_____invoke__h0653cfab9850c2f3: (a: number, b: number, c: any) => [number, number];
|
|
14
|
+
export const wasm_bindgen__convert__closures_____invoke__h133df7605c346924: (a: number, b: number, c: any, d: any) => void;
|
|
15
15
|
export const __wbindgen_malloc: (a: number, b: number) => number;
|
|
16
16
|
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
|
17
17
|
export const __wbindgen_exn_store: (a: number) => void;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
let rustModulePromise;
|
|
2
|
+
|
|
3
|
+
export async function loadRustModule() {
|
|
4
|
+
if (!rustModulePromise) {
|
|
5
|
+
rustModulePromise = (async () => {
|
|
6
|
+
try {
|
|
7
|
+
const moduleValue = await import("../pkg/simple_agents_wasm.js");
|
|
8
|
+
const wasmUrl = new URL("../pkg/simple_agents_wasm_bg.wasm", import.meta.url);
|
|
9
|
+
await moduleValue.default({ module_or_path: wasmUrl });
|
|
10
|
+
return moduleValue;
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
})();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return rustModulePromise;
|
|
18
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { runtimeError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
export function createStreamAggregator(model) {
|
|
4
|
+
return {
|
|
5
|
+
responseId: "",
|
|
6
|
+
responseModel: model,
|
|
7
|
+
aggregate: "",
|
|
8
|
+
finishReason: undefined
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function applyDeltaToAggregate(state, delta) {
|
|
13
|
+
if (!state || !delta) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!state.responseId && delta.id) {
|
|
18
|
+
state.responseId = delta.id;
|
|
19
|
+
}
|
|
20
|
+
if (delta.model) {
|
|
21
|
+
state.responseModel = delta.model;
|
|
22
|
+
}
|
|
23
|
+
if (delta.content) {
|
|
24
|
+
state.aggregate += delta.content;
|
|
25
|
+
}
|
|
26
|
+
if (delta.finishReason) {
|
|
27
|
+
state.finishReason = delta.finishReason;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createStreamEventBridge(model, onChunk) {
|
|
32
|
+
const aggregateState = createStreamAggregator(model);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
onEvent(event) {
|
|
36
|
+
if (event.eventType === "delta") {
|
|
37
|
+
const delta = event.delta;
|
|
38
|
+
if (!delta) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
applyDeltaToAggregate(aggregateState, delta);
|
|
43
|
+
|
|
44
|
+
onChunk({
|
|
45
|
+
id: delta.id,
|
|
46
|
+
model: delta.model,
|
|
47
|
+
content: delta.content,
|
|
48
|
+
finishReason: delta.finishReason,
|
|
49
|
+
raw: delta.raw
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (event.eventType === "error") {
|
|
54
|
+
onChunk({
|
|
55
|
+
id: aggregateState.responseId || "error",
|
|
56
|
+
model: aggregateState.responseModel,
|
|
57
|
+
error: event.error?.message ?? "stream error"
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
mergeResult(result, started) {
|
|
62
|
+
return {
|
|
63
|
+
...result,
|
|
64
|
+
id: result.id || aggregateState.responseId,
|
|
65
|
+
model: result.model || aggregateState.responseModel,
|
|
66
|
+
content: result.content ?? aggregateState.aggregate,
|
|
67
|
+
finishReason: result.finishReason ?? aggregateState.finishReason,
|
|
68
|
+
latencyMs: Math.max(0, Math.round(performance.now() - started))
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeSseChunk(chunk) {
|
|
75
|
+
return chunk.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseSseEventBlock(block) {
|
|
79
|
+
const lines = block.split("\n");
|
|
80
|
+
const dataLines = [];
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
if (line.startsWith("data:")) {
|
|
83
|
+
dataLines.push(line.slice(5).trimStart());
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (dataLines.length === 0) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const payload = dataLines.join("\n");
|
|
92
|
+
if (payload === "[DONE]") {
|
|
93
|
+
return { done: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
return { done: false, json: JSON.parse(payload), raw: payload };
|
|
98
|
+
} catch {
|
|
99
|
+
return { done: false, raw: payload };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function* iterateSse(response) {
|
|
104
|
+
if (!response.body) {
|
|
105
|
+
throw runtimeError("stream response had no body");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const reader = response.body.getReader();
|
|
109
|
+
const decoder = new TextDecoder();
|
|
110
|
+
let buffer = "";
|
|
111
|
+
|
|
112
|
+
while (true) {
|
|
113
|
+
const { value, done } = await reader.read();
|
|
114
|
+
if (done) {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
buffer += normalizeSseChunk(decoder.decode(value, { stream: true }));
|
|
119
|
+
let delimiterIndex = buffer.indexOf("\n\n");
|
|
120
|
+
while (delimiterIndex !== -1) {
|
|
121
|
+
const block = buffer.slice(0, delimiterIndex).trim();
|
|
122
|
+
buffer = buffer.slice(delimiterIndex + 2);
|
|
123
|
+
if (block.length > 0) {
|
|
124
|
+
yield block;
|
|
125
|
+
}
|
|
126
|
+
delimiterIndex = buffer.indexOf("\n\n");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
buffer += normalizeSseChunk(decoder.decode());
|
|
131
|
+
|
|
132
|
+
const trailing = buffer.trim();
|
|
133
|
+
if (trailing.length > 0) {
|
|
134
|
+
yield trailing;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/rust/Cargo.toml
CHANGED
package/rust/src/lib.rs
CHANGED
|
@@ -140,6 +140,7 @@ struct GraphSwitchBranch {
|
|
|
140
140
|
#[derive(Deserialize, Clone)]
|
|
141
141
|
struct GraphCustomWorker {
|
|
142
142
|
handler: Option<String>,
|
|
143
|
+
handler_file: Option<String>,
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
#[derive(Deserialize, Clone)]
|
|
@@ -223,6 +224,40 @@ struct WorkflowRunResult {
|
|
|
223
224
|
context: JsonValue,
|
|
224
225
|
output: Option<JsonValue>,
|
|
225
226
|
events: Vec<WorkflowRunEvent>,
|
|
227
|
+
#[serde(rename = "workflow_id", skip_serializing_if = "Option::is_none")]
|
|
228
|
+
workflow_id: Option<String>,
|
|
229
|
+
#[serde(rename = "entry_node", skip_serializing_if = "Option::is_none")]
|
|
230
|
+
entry_node: Option<String>,
|
|
231
|
+
#[serde(rename = "email_text", skip_serializing_if = "Option::is_none")]
|
|
232
|
+
email_text: Option<String>,
|
|
233
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
234
|
+
trace: Option<Vec<String>>,
|
|
235
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
236
|
+
outputs: Option<JsonValue>,
|
|
237
|
+
#[serde(rename = "terminal_node", skip_serializing_if = "Option::is_none")]
|
|
238
|
+
terminal_node: Option<String>,
|
|
239
|
+
#[serde(rename = "terminal_output", skip_serializing_if = "Option::is_none")]
|
|
240
|
+
terminal_output: Option<JsonValue>,
|
|
241
|
+
#[serde(rename = "step_timings", skip_serializing_if = "Option::is_none")]
|
|
242
|
+
step_timings: Option<JsonValue>,
|
|
243
|
+
#[serde(rename = "total_elapsed_ms", skip_serializing_if = "Option::is_none")]
|
|
244
|
+
total_elapsed_ms: Option<u64>,
|
|
245
|
+
#[serde(rename = "ttft_ms", skip_serializing_if = "Option::is_none")]
|
|
246
|
+
ttft_ms: Option<u64>,
|
|
247
|
+
#[serde(rename = "total_input_tokens", skip_serializing_if = "Option::is_none")]
|
|
248
|
+
total_input_tokens: Option<u64>,
|
|
249
|
+
#[serde(rename = "total_output_tokens", skip_serializing_if = "Option::is_none")]
|
|
250
|
+
total_output_tokens: Option<u64>,
|
|
251
|
+
#[serde(rename = "total_tokens", skip_serializing_if = "Option::is_none")]
|
|
252
|
+
total_tokens: Option<u64>,
|
|
253
|
+
#[serde(rename = "total_reasoning_tokens", skip_serializing_if = "Option::is_none")]
|
|
254
|
+
total_reasoning_tokens: Option<u64>,
|
|
255
|
+
#[serde(rename = "tokens_per_second", skip_serializing_if = "Option::is_none")]
|
|
256
|
+
tokens_per_second: Option<f64>,
|
|
257
|
+
#[serde(rename = "trace_id", skip_serializing_if = "Option::is_none")]
|
|
258
|
+
trace_id: Option<String>,
|
|
259
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
260
|
+
metadata: Option<JsonValue>,
|
|
226
261
|
}
|
|
227
262
|
|
|
228
263
|
#[derive(Deserialize)]
|
|
@@ -1316,7 +1351,7 @@ impl WasmClient {
|
|
|
1316
1351
|
}
|
|
1317
1352
|
|
|
1318
1353
|
if raw_doc.get("entry_node").is_some() && raw_doc.get("nodes").is_some() {
|
|
1319
|
-
let graph_doc: GraphWorkflowDoc = serde_json::from_value(raw_doc)
|
|
1354
|
+
let graph_doc: GraphWorkflowDoc = serde_json::from_value(raw_doc.clone())
|
|
1320
1355
|
.map_err(|error| config_error(format!("invalid graph workflow YAML: {error}")))?;
|
|
1321
1356
|
|
|
1322
1357
|
let mut node_by_id: HashMap<String, GraphWorkflowNode> = HashMap::new();
|
|
@@ -1342,10 +1377,19 @@ impl WasmClient {
|
|
|
1342
1377
|
"nodes": JsonValue::Object(JsonMap::new())
|
|
1343
1378
|
});
|
|
1344
1379
|
|
|
1380
|
+
let workflow_started = now_millis();
|
|
1345
1381
|
let mut events = Vec::new();
|
|
1346
1382
|
let mut output: Option<JsonValue> = None;
|
|
1347
1383
|
let mut pointer = graph_doc.entry_node.clone();
|
|
1348
1384
|
let mut iterations = 0usize;
|
|
1385
|
+
let mut workflow_ttft_ms: Option<u64> = None;
|
|
1386
|
+
let mut trace: Vec<String> = Vec::new();
|
|
1387
|
+
let mut step_timings: Vec<JsonValue> = Vec::new();
|
|
1388
|
+
let mut total_input_tokens: u64 = 0;
|
|
1389
|
+
let mut total_output_tokens: u64 = 0;
|
|
1390
|
+
let mut total_tokens: u64 = 0;
|
|
1391
|
+
let total_reasoning_tokens: u64 = 0;
|
|
1392
|
+
let mut llm_nodes_without_usage: Vec<String> = Vec::new();
|
|
1349
1393
|
|
|
1350
1394
|
while !pointer.is_empty() {
|
|
1351
1395
|
iterations += 1;
|
|
@@ -1373,6 +1417,12 @@ impl WasmClient {
|
|
|
1373
1417
|
status: "started".to_string(),
|
|
1374
1418
|
});
|
|
1375
1419
|
|
|
1420
|
+
let node_started = now_millis();
|
|
1421
|
+
let mut model_name: Option<String> = None;
|
|
1422
|
+
let mut prompt_tokens: Option<u64> = None;
|
|
1423
|
+
let mut completion_tokens: Option<u64> = None;
|
|
1424
|
+
let mut total_node_tokens: Option<u64> = None;
|
|
1425
|
+
|
|
1376
1426
|
if let Some(llm) = node.node_type.llm_call.as_ref() {
|
|
1377
1427
|
let model = llm
|
|
1378
1428
|
.model
|
|
@@ -1436,6 +1486,32 @@ impl WasmClient {
|
|
|
1436
1486
|
let completion: JsonValue = serde_wasm_bindgen::from_value(completion_js)
|
|
1437
1487
|
.map_err(|_| js_error("failed to parse completion result"))?;
|
|
1438
1488
|
|
|
1489
|
+
if workflow_ttft_ms.is_none() {
|
|
1490
|
+
let measured = (now_millis() - workflow_started).max(0.0) as u64;
|
|
1491
|
+
workflow_ttft_ms = Some(if measured == 0 { 1 } else { measured });
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
model_name = completion
|
|
1495
|
+
.get("model")
|
|
1496
|
+
.and_then(JsonValue::as_str)
|
|
1497
|
+
.map(ToString::to_string);
|
|
1498
|
+
let usage = completion.get("usage");
|
|
1499
|
+
let usage_available = completion
|
|
1500
|
+
.get("usage_available")
|
|
1501
|
+
.and_then(JsonValue::as_bool)
|
|
1502
|
+
.unwrap_or(false);
|
|
1503
|
+
if usage_available {
|
|
1504
|
+
prompt_tokens = usage
|
|
1505
|
+
.and_then(|value| value.get("prompt_tokens"))
|
|
1506
|
+
.and_then(JsonValue::as_u64);
|
|
1507
|
+
completion_tokens = usage
|
|
1508
|
+
.and_then(|value| value.get("completion_tokens"))
|
|
1509
|
+
.and_then(JsonValue::as_u64);
|
|
1510
|
+
total_node_tokens = usage
|
|
1511
|
+
.and_then(|value| value.get("total_tokens"))
|
|
1512
|
+
.and_then(JsonValue::as_u64);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1439
1515
|
let raw_content = completion
|
|
1440
1516
|
.get("content")
|
|
1441
1517
|
.and_then(JsonValue::as_str)
|
|
@@ -1493,28 +1569,35 @@ impl WasmClient {
|
|
|
1493
1569
|
.handler
|
|
1494
1570
|
.clone()
|
|
1495
1571
|
.unwrap_or_else(|| "custom_worker".to_string());
|
|
1572
|
+
let lookup_key = custom_worker
|
|
1573
|
+
.handler_file
|
|
1574
|
+
.as_deref()
|
|
1575
|
+
.map(|file| format!("{}#{}", file, handler.as_str()))
|
|
1576
|
+
.unwrap_or_else(|| handler.clone());
|
|
1496
1577
|
let functions_js = options.functions_js.clone().ok_or_else(|| {
|
|
1497
1578
|
config_error(format!(
|
|
1498
1579
|
"custom_worker node '{}' requires workflowOptions.functions",
|
|
1499
1580
|
node.id
|
|
1500
1581
|
))
|
|
1501
1582
|
})?;
|
|
1502
|
-
let function_value = Reflect::get(&functions_js, &JsValue::from_str(&
|
|
1583
|
+
let function_value = Reflect::get(&functions_js, &JsValue::from_str(&lookup_key))
|
|
1503
1584
|
.map_err(|_| {
|
|
1504
1585
|
config_error(format!(
|
|
1505
|
-
"failed to resolve custom worker handler '{}' from workflowOptions.functions",
|
|
1506
|
-
|
|
1586
|
+
"failed to resolve custom worker handler key '{}' from workflowOptions.functions",
|
|
1587
|
+
lookup_key
|
|
1507
1588
|
))
|
|
1508
1589
|
})?;
|
|
1509
1590
|
let function = function_value.dyn_into::<Function>().map_err(|_| {
|
|
1510
1591
|
config_error(format!(
|
|
1511
1592
|
"custom_worker node '{}' requires workflowOptions.functions['{}']",
|
|
1512
|
-
node.id,
|
|
1593
|
+
node.id, lookup_key
|
|
1513
1594
|
))
|
|
1514
1595
|
})?;
|
|
1515
1596
|
|
|
1516
1597
|
let worker_args = json!({
|
|
1517
1598
|
"handler": handler,
|
|
1599
|
+
"handler_file": custom_worker.handler_file,
|
|
1600
|
+
"handler_lookup_key": lookup_key,
|
|
1518
1601
|
"payload": node
|
|
1519
1602
|
.config
|
|
1520
1603
|
.as_ref()
|
|
@@ -1570,17 +1653,112 @@ impl WasmClient {
|
|
|
1570
1653
|
}
|
|
1571
1654
|
|
|
1572
1655
|
events.push(WorkflowRunEvent {
|
|
1573
|
-
step_id: node.id,
|
|
1656
|
+
step_id: node.id.clone(),
|
|
1574
1657
|
step_type: step_type.to_string(),
|
|
1575
1658
|
status: "completed".to_string(),
|
|
1576
1659
|
});
|
|
1660
|
+
let elapsed_ms = (now_millis() - node_started).max(0.0) as u64;
|
|
1661
|
+
if step_type == "llm_call" {
|
|
1662
|
+
if prompt_tokens.is_none() || completion_tokens.is_none() || total_node_tokens.is_none() {
|
|
1663
|
+
llm_nodes_without_usage.push(node.id.clone());
|
|
1664
|
+
}
|
|
1665
|
+
total_input_tokens += prompt_tokens.unwrap_or(0);
|
|
1666
|
+
total_output_tokens += completion_tokens.unwrap_or(0);
|
|
1667
|
+
total_tokens += total_node_tokens.unwrap_or(0);
|
|
1668
|
+
}
|
|
1669
|
+
let step_tokens_per_second = completion_tokens.map(|tokens| {
|
|
1670
|
+
if elapsed_ms == 0 {
|
|
1671
|
+
tokens as f64
|
|
1672
|
+
} else {
|
|
1673
|
+
((tokens as f64 / (elapsed_ms as f64 / 1000.0)) * 100.0).round() / 100.0
|
|
1674
|
+
}
|
|
1675
|
+
});
|
|
1676
|
+
step_timings.push(json!({
|
|
1677
|
+
"node_id": node.id.clone(),
|
|
1678
|
+
"node_kind": step_type,
|
|
1679
|
+
"model_name": model_name,
|
|
1680
|
+
"elapsed_ms": elapsed_ms,
|
|
1681
|
+
"prompt_tokens": prompt_tokens,
|
|
1682
|
+
"completion_tokens": completion_tokens,
|
|
1683
|
+
"total_tokens": total_node_tokens,
|
|
1684
|
+
"reasoning_tokens": 0,
|
|
1685
|
+
"tokens_per_second": step_tokens_per_second,
|
|
1686
|
+
}));
|
|
1687
|
+
trace.push(node.id.clone());
|
|
1577
1688
|
}
|
|
1578
1689
|
|
|
1690
|
+
let total_elapsed_ms = (now_millis() - workflow_started).max(0.0) as u64;
|
|
1691
|
+
let terminal_node = trace.last().cloned().unwrap_or_default();
|
|
1692
|
+
let workflow_id = raw_doc
|
|
1693
|
+
.get("id")
|
|
1694
|
+
.and_then(JsonValue::as_str)
|
|
1695
|
+
.unwrap_or("wasm_workflow")
|
|
1696
|
+
.to_string();
|
|
1697
|
+
let email_text = graph_context
|
|
1698
|
+
.get("input")
|
|
1699
|
+
.and_then(|input| input.get("email_text"))
|
|
1700
|
+
.and_then(JsonValue::as_str)
|
|
1701
|
+
.unwrap_or_default()
|
|
1702
|
+
.to_string();
|
|
1703
|
+
let outputs = graph_context
|
|
1704
|
+
.get("nodes")
|
|
1705
|
+
.cloned()
|
|
1706
|
+
.unwrap_or(JsonValue::Object(JsonMap::new()));
|
|
1707
|
+
let token_metrics_available = llm_nodes_without_usage.is_empty();
|
|
1708
|
+
let overall_tokens_per_second = if total_elapsed_ms == 0 {
|
|
1709
|
+
Some(total_output_tokens as f64)
|
|
1710
|
+
} else {
|
|
1711
|
+
Some(
|
|
1712
|
+
((total_output_tokens as f64 / (total_elapsed_ms as f64 / 1000.0)) * 100.0)
|
|
1713
|
+
.round()
|
|
1714
|
+
/ 100.0,
|
|
1715
|
+
)
|
|
1716
|
+
};
|
|
1717
|
+
let nerdstats = json!({
|
|
1718
|
+
"workflow_id": workflow_id,
|
|
1719
|
+
"terminal_node": terminal_node,
|
|
1720
|
+
"total_elapsed_ms": total_elapsed_ms,
|
|
1721
|
+
"ttft_ms": workflow_ttft_ms.unwrap_or(0),
|
|
1722
|
+
"step_details": step_timings,
|
|
1723
|
+
"total_input_tokens": total_input_tokens,
|
|
1724
|
+
"total_output_tokens": total_output_tokens,
|
|
1725
|
+
"total_tokens": total_tokens,
|
|
1726
|
+
"total_reasoning_tokens": total_reasoning_tokens,
|
|
1727
|
+
"tokens_per_second": overall_tokens_per_second,
|
|
1728
|
+
"trace_id": JsonValue::Null,
|
|
1729
|
+
"token_metrics_available": token_metrics_available,
|
|
1730
|
+
"token_metrics_source": if token_metrics_available { "provider_usage" } else { "unavailable" },
|
|
1731
|
+
"llm_nodes_without_usage": llm_nodes_without_usage,
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1579
1734
|
let result = WorkflowRunResult {
|
|
1580
1735
|
status: "ok".to_string(),
|
|
1581
1736
|
context: graph_context,
|
|
1582
|
-
output,
|
|
1737
|
+
output: output.clone(),
|
|
1583
1738
|
events,
|
|
1739
|
+
workflow_id: Some(
|
|
1740
|
+
raw_doc
|
|
1741
|
+
.get("id")
|
|
1742
|
+
.and_then(JsonValue::as_str)
|
|
1743
|
+
.unwrap_or("wasm_workflow")
|
|
1744
|
+
.to_string(),
|
|
1745
|
+
),
|
|
1746
|
+
entry_node: Some(graph_doc.entry_node),
|
|
1747
|
+
email_text: Some(email_text),
|
|
1748
|
+
trace: Some(trace),
|
|
1749
|
+
outputs: Some(outputs),
|
|
1750
|
+
terminal_node: Some(terminal_node),
|
|
1751
|
+
terminal_output: output,
|
|
1752
|
+
step_timings: Some(nerdstats.get("step_details").cloned().unwrap_or(JsonValue::Array(vec![]))),
|
|
1753
|
+
total_elapsed_ms: Some(total_elapsed_ms),
|
|
1754
|
+
ttft_ms: Some(workflow_ttft_ms.unwrap_or(0)),
|
|
1755
|
+
total_input_tokens: Some(total_input_tokens),
|
|
1756
|
+
total_output_tokens: Some(total_output_tokens),
|
|
1757
|
+
total_tokens: Some(total_tokens),
|
|
1758
|
+
total_reasoning_tokens: Some(total_reasoning_tokens),
|
|
1759
|
+
tokens_per_second: overall_tokens_per_second,
|
|
1760
|
+
trace_id: None,
|
|
1761
|
+
metadata: Some(json!({"nerdstats": nerdstats})),
|
|
1584
1762
|
};
|
|
1585
1763
|
return serde_wasm_bindgen::to_value(&result)
|
|
1586
1764
|
.map_err(|_| js_error("failed to serialize workflow result"));
|
|
@@ -1790,6 +1968,23 @@ impl WasmClient {
|
|
|
1790
1968
|
context: JsonValue::Object(context),
|
|
1791
1969
|
output,
|
|
1792
1970
|
events,
|
|
1971
|
+
workflow_id: None,
|
|
1972
|
+
entry_node: None,
|
|
1973
|
+
email_text: None,
|
|
1974
|
+
trace: None,
|
|
1975
|
+
outputs: None,
|
|
1976
|
+
terminal_node: None,
|
|
1977
|
+
terminal_output: None,
|
|
1978
|
+
step_timings: None,
|
|
1979
|
+
total_elapsed_ms: None,
|
|
1980
|
+
ttft_ms: None,
|
|
1981
|
+
total_input_tokens: None,
|
|
1982
|
+
total_output_tokens: None,
|
|
1983
|
+
total_tokens: None,
|
|
1984
|
+
total_reasoning_tokens: None,
|
|
1985
|
+
tokens_per_second: None,
|
|
1986
|
+
trace_id: None,
|
|
1987
|
+
metadata: None,
|
|
1793
1988
|
};
|
|
1794
1989
|
serde_wasm_bindgen::to_value(&result)
|
|
1795
1990
|
.map_err(|_| js_error("failed to serialize workflow result"))
|