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 CHANGED
@@ -103,6 +103,7 @@ export interface ClientConfig {
103
103
  export interface WorkflowRunOptions {
104
104
  telemetry?: Record<string, unknown>;
105
105
  trace?: Record<string, unknown>;
106
+ onEvent?: (event: Record<string, unknown>) => void;
106
107
  functions?: Record<
107
108
  string,
108
109
  (
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
- let responseId = "";
711
- let responseModel = model;
712
- let aggregate = "";
713
- let finishReason;
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
- if (!responseId && delta.id) {
743
- responseId = delta.id;
744
- }
745
- if (delta.model) {
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
- promptTokens: 0,
775
- completionTokens: 0,
776
- totalTokens: 0
754
+ ...usage
777
755
  },
778
- usageAvailable: false,
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
- const completion = await this.complete(model, promptOrMessages, {
878
- temperature: llm.temperature
879
- });
880
- const parsedOutput = maybeParseJson(completion.content ?? "");
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: completion.content ?? ""
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 fn = functions[handler];
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['${handler}']`
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
- status: "ok",
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
- output,
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
- status: "ok",
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
- output,
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(model, promptOrMessages, (event) => {
1118
- if (event.eventType === "delta") {
1119
- const delta = event.delta;
1120
- if (!delta) {
1121
- return;
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
- return rust.runWorkflowYamlString(yamlText, workflowInput, workflowOptions);
1255
+ const result = await rust.runWorkflowYamlString(yamlText, workflowInput, workflowOptions);
1256
+ return assertWorkflowResultShape(normalizeWorkflowResult(result));
1178
1257
  }
1179
- return this.fallbackClient.runWorkflowYamlString(yamlText, workflowInput, workflowOptions);
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
- return rust.runWorkflowYaml(workflowPath, workflowInput);
1269
+ const result = await rust.runWorkflowYaml(workflowPath, workflowInput);
1270
+ return assertWorkflowResultShape(normalizeWorkflowResult(result));
1186
1271
  }
1187
- return this.fallbackClient.runWorkflowYaml(workflowPath, workflowInput);
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.31",
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 wasm_bindgen__closure__destroy__h5b569a9b0c99a6ce: (a: number, b: number) => void;
31
- readonly wasm_bindgen__convert__closures_____invoke__h26e23bd7929d5711: (a: number, b: number, c: any) => [number, number];
32
- readonly wasm_bindgen__convert__closures_____invoke__h6589060427acb019: (a: number, b: number, c: any, d: any) => void;
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 wasm_bindgen__convert__closures_____invoke__h6589060427acb019(a, state0.b, arg0, arg1);
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.wasm_bindgen__closure__destroy__h5b569a9b0c99a6ce, wasm_bindgen__convert__closures_____invoke__h26e23bd7929d5711);
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 wasm_bindgen__convert__closures_____invoke__h26e23bd7929d5711(arg0, arg1, arg2) {
438
- const ret = wasm.wasm_bindgen__convert__closures_____invoke__h26e23bd7929d5711(arg0, arg1, arg2);
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 wasm_bindgen__convert__closures_____invoke__h6589060427acb019(arg0, arg1, arg2, arg3) {
445
- wasm.wasm_bindgen__convert__closures_____invoke__h6589060427acb019(arg0, arg1, arg2, arg3);
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 wasm_bindgen__closure__destroy__h5b569a9b0c99a6ce: (a: number, b: number) => void;
13
- export const wasm_bindgen__convert__closures_____invoke__h26e23bd7929d5711: (a: number, b: number, c: any) => [number, number];
14
- export const wasm_bindgen__convert__closures_____invoke__h6589060427acb019: (a: number, b: number, c: any, d: any) => void;
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,7 @@
1
+ export function configError(message) {
2
+ return new Error(`simple-agents-wasm config error: ${message}`);
3
+ }
4
+
5
+ export function runtimeError(message) {
6
+ return new Error(`simple-agents-wasm runtime error: ${message}`);
7
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "simple-agents-wasm-rust"
3
- version = "0.2.31"
3
+ version = "0.2.33"
4
4
  edition = "2021"
5
5
  license = "MIT OR Apache-2.0"
6
6
 
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(&handler))
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
- handler
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, handler
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"))