simple-agents-wasm 0.2.30 → 0.2.32

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
@@ -62,10 +62,175 @@ function toToolCalls(toolCalls) {
62
62
  }));
63
63
  }
64
64
 
65
+ function createStreamEventBridge(model, onChunk) {
66
+ let aggregate = "";
67
+ let finalId = "";
68
+ let finalModel = model;
69
+ let finalFinishReason;
70
+
71
+ return {
72
+ onEvent(event) {
73
+ if (event.eventType === "delta") {
74
+ const delta = event.delta;
75
+ if (!delta) {
76
+ return;
77
+ }
78
+
79
+ if (!finalId && delta.id) {
80
+ finalId = delta.id;
81
+ }
82
+ if (delta.model) {
83
+ finalModel = delta.model;
84
+ }
85
+ if (delta.content) {
86
+ aggregate += delta.content;
87
+ }
88
+ if (delta.finishReason) {
89
+ finalFinishReason = delta.finishReason;
90
+ }
91
+
92
+ onChunk({
93
+ id: delta.id,
94
+ model: delta.model,
95
+ content: delta.content,
96
+ finishReason: delta.finishReason,
97
+ raw: delta.raw
98
+ });
99
+ }
100
+
101
+ if (event.eventType === "error") {
102
+ onChunk({
103
+ id: finalId || "error",
104
+ model: finalModel,
105
+ error: event.error?.message ?? "stream error"
106
+ });
107
+ }
108
+ },
109
+ mergeResult(result, started) {
110
+ return {
111
+ ...result,
112
+ id: result.id || finalId,
113
+ model: result.model || finalModel,
114
+ content: result.content ?? aggregate,
115
+ finishReason: result.finishReason ?? finalFinishReason,
116
+ latencyMs: Math.max(0, Math.round(performance.now() - started))
117
+ };
118
+ }
119
+ };
120
+ }
121
+
122
+ function assertWorkflowResultShape(result) {
123
+ if (result === null || typeof result !== "object") {
124
+ throw runtimeError(
125
+ "workflow result contract mismatch: expected an object with workflow_id and outputs"
126
+ );
127
+ }
128
+
129
+ if (!("workflow_id" in result) || !("outputs" in result)) {
130
+ throw runtimeError(
131
+ "workflow result contract mismatch: expected keys 'workflow_id' and 'outputs'"
132
+ );
133
+ }
134
+
135
+ return result;
136
+ }
137
+
138
+ function normalizeWorkflowResult(result) {
139
+ if (result === null || typeof result !== "object") {
140
+ return result;
141
+ }
142
+ if ("workflow_id" in result && "outputs" in result) {
143
+ return result;
144
+ }
145
+ if (!("context" in result) || !result.context || typeof result.context !== "object") {
146
+ return result;
147
+ }
148
+
149
+ const context = result.context;
150
+ const nodeOutputs =
151
+ context && typeof context === "object" && context.nodes && typeof context.nodes === "object"
152
+ ? context.nodes
153
+ : context;
154
+ const trace = Array.isArray(result.events)
155
+ ? result.events
156
+ .filter((event) => event && event.status === "completed" && typeof event.stepId === "string")
157
+ .map((event) => event.stepId)
158
+ : [];
159
+ const terminalNode = trace.at(-1) ?? "";
160
+
161
+ return {
162
+ workflow_id: typeof result.workflow_id === "string" ? result.workflow_id : "wasm_workflow",
163
+ entry_node: typeof result.entry_node === "string" ? result.entry_node : trace[0] ?? "",
164
+ email_text: typeof context?.input?.email_text === "string" ? context.input.email_text : "",
165
+ trace,
166
+ outputs: nodeOutputs,
167
+ terminal_node: typeof result.terminal_node === "string" ? result.terminal_node : terminalNode,
168
+ terminal_output: result.output,
169
+ events: Array.isArray(result.events) ? result.events : [],
170
+ status: typeof result.status === "string" ? result.status : "ok"
171
+ };
172
+ }
173
+
65
174
  function normalizeBaseUrl(baseUrl) {
66
175
  return baseUrl.replace(/\/$/, "");
67
176
  }
68
177
 
178
+ function finiteNumberOrNull(value) {
179
+ return Number.isFinite(value) ? value : null;
180
+ }
181
+
182
+ function buildStepDetail(step) {
183
+ const detail = {
184
+ elapsed_ms: step.elapsedMs,
185
+ node_id: step.nodeId,
186
+ node_kind: step.nodeKind
187
+ };
188
+
189
+ if (step.nodeKind === "llm_call") {
190
+ if (typeof step.modelName === "string") {
191
+ detail.model_name = step.modelName;
192
+ }
193
+ const promptTokens = finiteNumberOrNull(step.promptTokens);
194
+ if (promptTokens !== null) {
195
+ detail.prompt_tokens = promptTokens;
196
+ }
197
+ const completionTokens = finiteNumberOrNull(step.completionTokens);
198
+ if (completionTokens !== null) {
199
+ detail.completion_tokens = completionTokens;
200
+ }
201
+ const totalTokens = finiteNumberOrNull(step.totalTokens);
202
+ if (totalTokens !== null) {
203
+ detail.total_tokens = totalTokens;
204
+ }
205
+ detail.reasoning_tokens = 0;
206
+ const tokensPerSecond = finiteNumberOrNull(step.tokensPerSecond);
207
+ if (tokensPerSecond !== null) {
208
+ detail.tokens_per_second = tokensPerSecond;
209
+ }
210
+ }
211
+
212
+ return detail;
213
+ }
214
+
215
+ function buildWorkflowNerdstats(summary) {
216
+ return {
217
+ workflow_id: summary.workflowId,
218
+ terminal_node: summary.terminalNode,
219
+ total_elapsed_ms: summary.totalElapsedMs,
220
+ ttft_ms: summary.ttftMs,
221
+ step_details: summary.stepDetails,
222
+ total_input_tokens: summary.totalInputTokens,
223
+ total_output_tokens: summary.totalOutputTokens,
224
+ total_tokens: summary.totalTokens,
225
+ total_reasoning_tokens: summary.totalReasoningTokens,
226
+ tokens_per_second: summary.tokensPerSecond,
227
+ trace_id: summary.traceId,
228
+ token_metrics_available: summary.tokenMetricsAvailable,
229
+ token_metrics_source: summary.tokenMetricsSource,
230
+ llm_nodes_without_usage: summary.llmNodesWithoutUsage
231
+ };
232
+ }
233
+
69
234
  function interpolate(value, context) {
70
235
  if (typeof value === "string") {
71
236
  return value.replace(/{{\s*([^}]+)\s*}}/g, (_, key) => {
@@ -179,6 +344,35 @@ function interpolatePathTemplate(template, context) {
179
344
  });
180
345
  }
181
346
 
347
+ function interpolatePathValue(value, context) {
348
+ if (typeof value === "string") {
349
+ return value.replace(/{{\s*([^}]+)\s*}}/g, (_, token) => {
350
+ const resolved = getPathValue(context, token);
351
+ if (resolved === null || resolved === undefined) {
352
+ return "";
353
+ }
354
+ if (typeof resolved === "string") {
355
+ return resolved;
356
+ }
357
+ return JSON.stringify(resolved);
358
+ });
359
+ }
360
+
361
+ if (Array.isArray(value)) {
362
+ return value.map((entry) => interpolatePathValue(entry, context));
363
+ }
364
+
365
+ if (value !== null && value !== undefined && typeof value === "object") {
366
+ const output = {};
367
+ for (const [key, nested] of Object.entries(value)) {
368
+ output[key] = interpolatePathValue(nested, context);
369
+ }
370
+ return output;
371
+ }
372
+
373
+ return value;
374
+ }
375
+
182
376
  function maybeParseJson(text) {
183
377
  if (typeof text !== "string") {
184
378
  return text;
@@ -584,63 +778,17 @@ class BrowserJsClient {
584
778
  throw configError("onChunk callback is required");
585
779
  }
586
780
 
587
- let aggregate = "";
588
- let finalId = "";
589
- let finalModel = model;
590
- let finalFinishReason;
591
781
  const started = performance.now();
782
+ const streamBridge = createStreamEventBridge(model, onChunk);
592
783
 
593
784
  const result = await this.streamEvents(
594
785
  model,
595
786
  promptOrMessages,
596
- (event) => {
597
- if (event.eventType === "delta") {
598
- const delta = event.delta;
599
- if (!delta) {
600
- return;
601
- }
602
-
603
- if (delta.id && finalId.length === 0) {
604
- finalId = delta.id;
605
- }
606
- if (delta.model) {
607
- finalModel = delta.model;
608
- }
609
- if (delta.content) {
610
- aggregate += delta.content;
611
- }
612
- if (delta.finishReason) {
613
- finalFinishReason = delta.finishReason;
614
- }
615
-
616
- onChunk({
617
- id: delta.id,
618
- model: delta.model,
619
- content: delta.content,
620
- finishReason: delta.finishReason,
621
- raw: delta.raw
622
- });
623
- }
624
-
625
- if (event.eventType === "error") {
626
- onChunk({
627
- id: finalId || "error",
628
- model: finalModel,
629
- error: event.error?.message ?? "stream error"
630
- });
631
- }
632
- },
787
+ (event) => streamBridge.onEvent(event),
633
788
  options
634
789
  );
635
790
 
636
- return {
637
- ...result,
638
- id: result.id || finalId,
639
- model: result.model || finalModel,
640
- content: result.content ?? aggregate,
641
- finishReason: result.finishReason ?? finalFinishReason,
642
- latencyMs: Math.max(0, Math.round(performance.now() - started))
643
- };
791
+ return streamBridge.mergeResult(result, started);
644
792
  }
645
793
 
646
794
  async streamEvents(model, promptOrMessages, onEvent, options = {}) {
@@ -665,7 +813,10 @@ class BrowserJsClient {
665
813
  max_tokens: options.maxTokens,
666
814
  temperature: options.temperature,
667
815
  top_p: options.topP,
668
- stream: true
816
+ stream: true,
817
+ stream_options: {
818
+ include_usage: true
819
+ }
669
820
  })
670
821
  });
671
822
 
@@ -682,6 +833,12 @@ class BrowserJsClient {
682
833
  let responseModel = model;
683
834
  let aggregate = "";
684
835
  let finishReason;
836
+ let usage = {
837
+ promptTokens: 0,
838
+ completionTokens: 0,
839
+ totalTokens: 0
840
+ };
841
+ let usageAvailable = false;
685
842
 
686
843
  try {
687
844
  for await (const block of iterateSse(response)) {
@@ -722,6 +879,10 @@ class BrowserJsClient {
722
879
  if (delta.finishReason) {
723
880
  finishReason = delta.finishReason;
724
881
  }
882
+ if (chunk?.usage && typeof chunk.usage === "object") {
883
+ usage = toUsage(chunk.usage);
884
+ usageAvailable = true;
885
+ }
725
886
 
726
887
  onEvent({ eventType: "delta", delta });
727
888
  }
@@ -742,11 +903,9 @@ class BrowserJsClient {
742
903
  content: aggregate,
743
904
  finishReason,
744
905
  usage: {
745
- promptTokens: 0,
746
- completionTokens: 0,
747
- totalTokens: 0
906
+ ...usage
748
907
  },
749
- usageAvailable: false,
908
+ usageAvailable,
750
909
  latencyMs,
751
910
  raw: undefined,
752
911
  healed: undefined,
@@ -797,9 +956,17 @@ class BrowserJsClient {
797
956
  nodes: {}
798
957
  };
799
958
 
959
+ const workflowStarted = performance.now();
960
+ let workflowTtftMs = 0;
800
961
  let pointer = doc.entry_node;
801
962
  let output;
802
963
  let iterations = 0;
964
+ const stepDetails = [];
965
+ let totalInputTokens = 0;
966
+ let totalOutputTokens = 0;
967
+ let totalTokens = 0;
968
+ const totalReasoningTokens = 0;
969
+ const llmNodesWithoutUsage = [];
803
970
 
804
971
  while (typeof pointer === "string" && pointer.length > 0) {
805
972
  iterations += 1;
@@ -814,6 +981,11 @@ class BrowserJsClient {
814
981
 
815
982
  const nodeType = node.node_type ?? {};
816
983
  const nodeTypeName = Object.keys(nodeType)[0] ?? "unknown";
984
+ const nodeStarted = performance.now();
985
+ let stepModelName;
986
+ let stepPromptTokens;
987
+ let stepCompletionTokens;
988
+ let stepTotalTokens;
817
989
  events.push({ stepId: node.id, stepType: nodeTypeName, status: "started" });
818
990
 
819
991
  if (nodeType.llm_call) {
@@ -845,10 +1017,57 @@ class BrowserJsClient {
845
1017
  promptOrMessages = history;
846
1018
  }
847
1019
 
848
- const completion = await this.complete(model, promptOrMessages, {
849
- temperature: llm.temperature
850
- });
851
- const parsedOutput = maybeParseJson(completion.content ?? "");
1020
+ let rawContent = "";
1021
+ let completion;
1022
+ if (llm.stream === true) {
1023
+ completion = await this.streamEvents(
1024
+ model,
1025
+ promptOrMessages,
1026
+ (event) => {
1027
+ if (event && event.eventType === "delta" && typeof event.delta?.content === "string") {
1028
+ if (workflowTtftMs === 0) {
1029
+ const measured = Math.max(0, Math.round(performance.now() - workflowStarted));
1030
+ workflowTtftMs = measured === 0 ? 1 : measured;
1031
+ }
1032
+ rawContent += event.delta.content;
1033
+ if (typeof workflowOptions?.onEvent === "function") {
1034
+ workflowOptions.onEvent({
1035
+ eventType: "node_stream_delta",
1036
+ nodeId: node.id,
1037
+ delta: event.delta.content,
1038
+ model: event.delta.model ?? model
1039
+ });
1040
+ }
1041
+ }
1042
+ },
1043
+ {
1044
+ temperature: llm.temperature
1045
+ }
1046
+ );
1047
+ rawContent = completion.content ?? rawContent;
1048
+ } else {
1049
+ completion = await this.complete(model, promptOrMessages, {
1050
+ temperature: llm.temperature
1051
+ });
1052
+ rawContent = completion.content ?? "";
1053
+ }
1054
+
1055
+ stepModelName = completion?.model ?? model;
1056
+ if (completion?.usageAvailable === true && completion?.usage && typeof completion.usage === "object") {
1057
+ const usage = completion.usage;
1058
+ stepPromptTokens = Number.isFinite(usage.promptTokens) ? usage.promptTokens : 0;
1059
+ stepCompletionTokens = Number.isFinite(usage.completionTokens)
1060
+ ? usage.completionTokens
1061
+ : 0;
1062
+ stepTotalTokens = Number.isFinite(usage.totalTokens) ? usage.totalTokens : 0;
1063
+ totalInputTokens += stepPromptTokens;
1064
+ totalOutputTokens += stepCompletionTokens;
1065
+ totalTokens += stepTotalTokens;
1066
+ } else {
1067
+ llmNodesWithoutUsage.push(node.id);
1068
+ }
1069
+
1070
+ const parsedOutput = maybeParseJson(rawContent);
852
1071
  const validationError = schemaValidationError(llmOutputSchema(node), parsedOutput);
853
1072
  if (validationError !== null) {
854
1073
  throw runtimeError(
@@ -857,7 +1076,7 @@ class BrowserJsClient {
857
1076
  }
858
1077
  graphContext.nodes[node.id] = {
859
1078
  output: parsedOutput,
860
- raw: completion.content ?? ""
1079
+ raw: rawContent
861
1080
  };
862
1081
  output = parsedOutput;
863
1082
 
@@ -872,16 +1091,23 @@ class BrowserJsClient {
872
1091
  pointer = matched?.target ?? switchSpec.default ?? "";
873
1092
  } else if (nodeType.custom_worker) {
874
1093
  const handler = nodeType.custom_worker.handler ?? "custom_worker";
875
- const fn = functions[handler];
1094
+ const handlerFile = nodeType.custom_worker.handler_file;
1095
+ const lookupKey =
1096
+ typeof handlerFile === "string" && handlerFile.length > 0
1097
+ ? `${handlerFile}#${handler}`
1098
+ : handler;
1099
+ const fn = functions[lookupKey];
876
1100
  if (typeof fn !== "function") {
877
1101
  throw runtimeError(
878
- `custom_worker node '${node.id}' requires workflowOptions.functions['${handler}']`
1102
+ `custom_worker node '${node.id}' requires workflowOptions.functions['${lookupKey}']`
879
1103
  );
880
1104
  }
881
1105
  const workerOutput = await fn(
882
1106
  {
883
1107
  handler,
884
- payload: node.config?.payload ?? null,
1108
+ handler_file: handlerFile,
1109
+ handler_lookup_key: lookupKey,
1110
+ payload: interpolatePathValue(node.config?.payload ?? null, graphContext),
885
1111
  nodeId: node.id
886
1112
  },
887
1113
  graphContext
@@ -897,13 +1123,92 @@ class BrowserJsClient {
897
1123
  }
898
1124
 
899
1125
  events.push({ stepId: node.id, stepType: nodeTypeName, status: "completed" });
1126
+ const elapsedMs = Math.max(0, Math.round(performance.now() - nodeStarted));
1127
+ const stepTokensPerSecond =
1128
+ Number.isFinite(stepCompletionTokens) && elapsedMs > 0
1129
+ ? Math.round((stepCompletionTokens / (elapsedMs / 1000)) * 100) / 100
1130
+ : null;
1131
+ stepDetails.push(
1132
+ buildStepDetail({
1133
+ nodeId: node.id,
1134
+ nodeKind: nodeTypeName,
1135
+ modelName: stepModelName,
1136
+ elapsedMs,
1137
+ promptTokens: stepPromptTokens,
1138
+ completionTokens: stepCompletionTokens,
1139
+ totalTokens: stepTotalTokens,
1140
+ tokensPerSecond: stepTokensPerSecond
1141
+ })
1142
+ );
1143
+ }
1144
+
1145
+ const trace = events
1146
+ .filter((event) => event && event.status === "completed")
1147
+ .map((event) => event.stepId);
1148
+ const terminalNode = trace.at(-1) ?? "";
1149
+ const totalElapsedMs = Math.max(0, Math.round(performance.now() - workflowStarted));
1150
+ const tokenMetricsAvailable = llmNodesWithoutUsage.length === 0;
1151
+ const overallTokensPerSecond =
1152
+ totalElapsedMs > 0 ? Math.round((totalOutputTokens / (totalElapsedMs / 1000)) * 100) / 100 : 0;
1153
+ const workflowId =
1154
+ typeof doc.id === "string" && doc.id.length > 0 ? doc.id : "browser_js_workflow";
1155
+ const nerdstats = buildWorkflowNerdstats({
1156
+ workflowId,
1157
+ terminalNode,
1158
+ totalElapsedMs,
1159
+ ttftMs: workflowTtftMs,
1160
+ stepDetails,
1161
+ totalInputTokens,
1162
+ totalOutputTokens,
1163
+ totalTokens,
1164
+ totalReasoningTokens,
1165
+ tokensPerSecond: overallTokensPerSecond,
1166
+ traceId: "",
1167
+ tokenMetricsAvailable,
1168
+ tokenMetricsSource: tokenMetricsAvailable ? "provider_usage" : "unavailable",
1169
+ llmNodesWithoutUsage
1170
+ });
1171
+ if (typeof workflowOptions?.onEvent === "function") {
1172
+ workflowOptions.onEvent({
1173
+ event_type: "workflow_completed",
1174
+ metadata: {
1175
+ nerdstats
1176
+ }
1177
+ });
1178
+ }
1179
+
1180
+ const outputs = {};
1181
+ for (const [nodeId, nodeValue] of Object.entries(graphContext.nodes ?? {})) {
1182
+ if (nodeValue && typeof nodeValue === "object" && "output" in nodeValue) {
1183
+ outputs[nodeId] = nodeValue.output;
1184
+ } else {
1185
+ outputs[nodeId] = nodeValue;
1186
+ }
900
1187
  }
901
1188
 
902
1189
  return {
903
- status: "ok",
1190
+ workflow_id: workflowId,
1191
+ entry_node: doc.entry_node,
1192
+ email_text: typeof graphContext.input?.email_text === "string" ? graphContext.input.email_text : "",
1193
+ trace,
1194
+ outputs,
1195
+ terminal_node: terminalNode,
1196
+ terminal_output: output,
1197
+ step_timings: stepDetails,
1198
+ total_elapsed_ms: totalElapsedMs,
1199
+ ttft_ms: workflowTtftMs,
1200
+ total_input_tokens: totalInputTokens,
1201
+ total_output_tokens: totalOutputTokens,
1202
+ total_tokens: totalTokens,
1203
+ total_reasoning_tokens: totalReasoningTokens,
1204
+ tokens_per_second: overallTokensPerSecond,
1205
+ trace_id: "",
1206
+ metadata: {
1207
+ nerdstats
1208
+ },
1209
+ events,
904
1210
  context: graphContext,
905
- output,
906
- events
1211
+ status: "ok"
907
1212
  };
908
1213
  }
909
1214
 
@@ -992,10 +1297,22 @@ class BrowserJsClient {
992
1297
  }
993
1298
 
994
1299
  return {
995
- status: "ok",
1300
+ workflow_id:
1301
+ typeof doc.id === "string" && doc.id.length > 0 ? doc.id : "browser_js_workflow",
1302
+ entry_node: typeof doc.steps?.[0]?.id === "string" ? doc.steps[0].id : "",
1303
+ email_text: typeof workflowInput?.email_text === "string" ? workflowInput.email_text : "",
1304
+ trace: events
1305
+ .filter((event) => event && event.status === "completed")
1306
+ .map((event) => event.stepId),
1307
+ outputs: { ...context },
1308
+ terminal_node: events
1309
+ .filter((event) => event && event.status === "completed")
1310
+ .map((event) => event.stepId)
1311
+ .at(-1) ?? "",
1312
+ terminal_output: output,
1313
+ events,
996
1314
  context,
997
- output,
998
- events
1315
+ status: "ok"
999
1316
  };
1000
1317
  }
1001
1318
 
@@ -1014,7 +1331,7 @@ async function loadRustModule() {
1014
1331
  try {
1015
1332
  const moduleValue = await import("./pkg/simple_agents_wasm.js");
1016
1333
  const wasmUrl = new URL("./pkg/simple_agents_wasm_bg.wasm", import.meta.url);
1017
- await moduleValue.default(wasmUrl);
1334
+ await moduleValue.default({ module_or_path: wasmUrl });
1018
1335
  return moduleValue;
1019
1336
  } catch {
1020
1337
  return null;
@@ -1079,56 +1396,17 @@ export class Client {
1079
1396
  async stream(model, promptOrMessages, onChunk, options = {}) {
1080
1397
  const rust = await this.ensureBackend();
1081
1398
  if (rust) {
1082
- let aggregate = "";
1083
- let finalId = "";
1084
- let finalModel = model;
1085
- let finalFinishReason;
1086
1399
  const started = performance.now();
1400
+ const streamBridge = createStreamEventBridge(model, onChunk);
1087
1401
 
1088
- const result = await rust.streamEvents(model, promptOrMessages, (event) => {
1089
- if (event.eventType === "delta") {
1090
- const delta = event.delta;
1091
- if (!delta) {
1092
- return;
1093
- }
1094
- if (!finalId && delta.id) {
1095
- finalId = delta.id;
1096
- }
1097
- if (delta.model) {
1098
- finalModel = delta.model;
1099
- }
1100
- if (delta.content) {
1101
- aggregate += delta.content;
1102
- }
1103
- if (delta.finishReason) {
1104
- finalFinishReason = delta.finishReason;
1105
- }
1106
- onChunk({
1107
- id: delta.id,
1108
- model: delta.model,
1109
- content: delta.content,
1110
- finishReason: delta.finishReason,
1111
- raw: delta.raw
1112
- });
1113
- }
1114
-
1115
- if (event.eventType === "error") {
1116
- onChunk({
1117
- id: finalId || "error",
1118
- model: finalModel,
1119
- error: event.error?.message ?? "stream error"
1120
- });
1121
- }
1122
- }, options);
1402
+ const result = await rust.streamEvents(
1403
+ model,
1404
+ promptOrMessages,
1405
+ (event) => streamBridge.onEvent(event),
1406
+ options
1407
+ );
1123
1408
 
1124
- return {
1125
- ...result,
1126
- id: result.id || finalId,
1127
- model: result.model || finalModel,
1128
- content: result.content ?? aggregate,
1129
- finishReason: result.finishReason ?? finalFinishReason,
1130
- latencyMs: Math.max(0, Math.round(performance.now() - started))
1131
- };
1409
+ return streamBridge.mergeResult(result, started);
1132
1410
  }
1133
1411
 
1134
1412
  return this.fallbackClient.stream(model, promptOrMessages, onChunk, options);
@@ -1145,17 +1423,25 @@ export class Client {
1145
1423
  async runWorkflowYamlString(yamlText, workflowInput, workflowOptions) {
1146
1424
  const rust = await this.ensureBackend();
1147
1425
  if (rust) {
1148
- return rust.runWorkflowYamlString(yamlText, workflowInput, workflowOptions);
1426
+ const result = await rust.runWorkflowYamlString(yamlText, workflowInput, workflowOptions);
1427
+ return assertWorkflowResultShape(normalizeWorkflowResult(result));
1149
1428
  }
1150
- return this.fallbackClient.runWorkflowYamlString(yamlText, workflowInput, workflowOptions);
1429
+ const result = await this.fallbackClient.runWorkflowYamlString(
1430
+ yamlText,
1431
+ workflowInput,
1432
+ workflowOptions
1433
+ );
1434
+ return assertWorkflowResultShape(normalizeWorkflowResult(result));
1151
1435
  }
1152
1436
 
1153
1437
  async runWorkflowYaml(workflowPath, workflowInput) {
1154
1438
  const rust = await this.ensureBackend();
1155
1439
  if (rust) {
1156
- return rust.runWorkflowYaml(workflowPath, workflowInput);
1440
+ const result = await rust.runWorkflowYaml(workflowPath, workflowInput);
1441
+ return assertWorkflowResultShape(normalizeWorkflowResult(result));
1157
1442
  }
1158
- return this.fallbackClient.runWorkflowYaml(workflowPath, workflowInput);
1443
+ const result = await this.fallbackClient.runWorkflowYaml(workflowPath, workflowInput);
1444
+ return assertWorkflowResultShape(normalizeWorkflowResult(result));
1159
1445
  }
1160
1446
  }
1161
1447
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simple-agents-wasm",
3
- "version": "0.2.30",
3
+ "version": "0.2.32",
4
4
  "description": "Browser-compatible SimpleAgents client for OpenAI-compatible providers",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -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;
package/rust/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "simple-agents-wasm-rust"
3
- version = "0.2.30"
3
+ version = "0.2.32"
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)]
@@ -538,6 +573,24 @@ fn interpolate_graph_prompt(input: &str, context: &JsonValue) -> String {
538
573
  output
539
574
  }
540
575
 
576
+ fn interpolate_graph_json(value: &JsonValue, context: &JsonValue) -> JsonValue {
577
+ match value {
578
+ JsonValue::String(s) => JsonValue::String(interpolate_graph_prompt(s, context)),
579
+ JsonValue::Array(items) => JsonValue::Array(
580
+ items
581
+ .iter()
582
+ .map(|item| interpolate_graph_json(item, context))
583
+ .collect(),
584
+ ),
585
+ JsonValue::Object(map) => JsonValue::Object(
586
+ map.iter()
587
+ .map(|(key, value)| (key.clone(), interpolate_graph_json(value, context)))
588
+ .collect(),
589
+ ),
590
+ _ => value.clone(),
591
+ }
592
+ }
593
+
541
594
  fn parse_json_from_text(value: &str) -> JsonValue {
542
595
  if let Ok(parsed) = serde_json::from_str::<JsonValue>(value) {
543
596
  return parsed;
@@ -1298,7 +1351,7 @@ impl WasmClient {
1298
1351
  }
1299
1352
 
1300
1353
  if raw_doc.get("entry_node").is_some() && raw_doc.get("nodes").is_some() {
1301
- let graph_doc: GraphWorkflowDoc = serde_json::from_value(raw_doc)
1354
+ let graph_doc: GraphWorkflowDoc = serde_json::from_value(raw_doc.clone())
1302
1355
  .map_err(|error| config_error(format!("invalid graph workflow YAML: {error}")))?;
1303
1356
 
1304
1357
  let mut node_by_id: HashMap<String, GraphWorkflowNode> = HashMap::new();
@@ -1324,10 +1377,19 @@ impl WasmClient {
1324
1377
  "nodes": JsonValue::Object(JsonMap::new())
1325
1378
  });
1326
1379
 
1380
+ let workflow_started = now_millis();
1327
1381
  let mut events = Vec::new();
1328
1382
  let mut output: Option<JsonValue> = None;
1329
1383
  let mut pointer = graph_doc.entry_node.clone();
1330
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();
1331
1393
 
1332
1394
  while !pointer.is_empty() {
1333
1395
  iterations += 1;
@@ -1355,6 +1417,12 @@ impl WasmClient {
1355
1417
  status: "started".to_string(),
1356
1418
  });
1357
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
+
1358
1426
  if let Some(llm) = node.node_type.llm_call.as_ref() {
1359
1427
  let model = llm
1360
1428
  .model
@@ -1418,6 +1486,32 @@ impl WasmClient {
1418
1486
  let completion: JsonValue = serde_wasm_bindgen::from_value(completion_js)
1419
1487
  .map_err(|_| js_error("failed to parse completion result"))?;
1420
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
+
1421
1515
  let raw_content = completion
1422
1516
  .get("content")
1423
1517
  .and_then(JsonValue::as_str)
@@ -1475,32 +1569,40 @@ impl WasmClient {
1475
1569
  .handler
1476
1570
  .clone()
1477
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());
1478
1577
  let functions_js = options.functions_js.clone().ok_or_else(|| {
1479
1578
  config_error(format!(
1480
1579
  "custom_worker node '{}' requires workflowOptions.functions",
1481
1580
  node.id
1482
1581
  ))
1483
1582
  })?;
1484
- let function_value = Reflect::get(&functions_js, &JsValue::from_str(&handler))
1583
+ let function_value = Reflect::get(&functions_js, &JsValue::from_str(&lookup_key))
1485
1584
  .map_err(|_| {
1486
1585
  config_error(format!(
1487
- "failed to resolve custom worker handler '{}' from workflowOptions.functions",
1488
- handler
1586
+ "failed to resolve custom worker handler key '{}' from workflowOptions.functions",
1587
+ lookup_key
1489
1588
  ))
1490
1589
  })?;
1491
1590
  let function = function_value.dyn_into::<Function>().map_err(|_| {
1492
1591
  config_error(format!(
1493
1592
  "custom_worker node '{}' requires workflowOptions.functions['{}']",
1494
- node.id, handler
1593
+ node.id, lookup_key
1495
1594
  ))
1496
1595
  })?;
1497
1596
 
1498
1597
  let worker_args = json!({
1499
1598
  "handler": handler,
1599
+ "handler_file": custom_worker.handler_file,
1600
+ "handler_lookup_key": lookup_key,
1500
1601
  "payload": node
1501
1602
  .config
1502
1603
  .as_ref()
1503
- .and_then(|config| config.payload.clone())
1604
+ .and_then(|config| config.payload.as_ref())
1605
+ .map(|payload| interpolate_graph_json(payload, &graph_context))
1504
1606
  .unwrap_or(JsonValue::Null),
1505
1607
  "nodeId": node.id.clone()
1506
1608
  });
@@ -1551,17 +1653,112 @@ impl WasmClient {
1551
1653
  }
1552
1654
 
1553
1655
  events.push(WorkflowRunEvent {
1554
- step_id: node.id,
1656
+ step_id: node.id.clone(),
1555
1657
  step_type: step_type.to_string(),
1556
1658
  status: "completed".to_string(),
1557
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());
1558
1688
  }
1559
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
+
1560
1734
  let result = WorkflowRunResult {
1561
1735
  status: "ok".to_string(),
1562
1736
  context: graph_context,
1563
- output,
1737
+ output: output.clone(),
1564
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})),
1565
1762
  };
1566
1763
  return serde_wasm_bindgen::to_value(&result)
1567
1764
  .map_err(|_| js_error("failed to serialize workflow result"));
@@ -1771,6 +1968,23 @@ impl WasmClient {
1771
1968
  context: JsonValue::Object(context),
1772
1969
  output,
1773
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,
1774
1988
  };
1775
1989
  serde_wasm_bindgen::to_value(&result)
1776
1990
  .map_err(|_| js_error("failed to serialize workflow result"))