simple-agents-wasm 0.2.31 → 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) => {
@@ -613,63 +778,17 @@ class BrowserJsClient {
613
778
  throw configError("onChunk callback is required");
614
779
  }
615
780
 
616
- let aggregate = "";
617
- let finalId = "";
618
- let finalModel = model;
619
- let finalFinishReason;
620
781
  const started = performance.now();
782
+ const streamBridge = createStreamEventBridge(model, onChunk);
621
783
 
622
784
  const result = await this.streamEvents(
623
785
  model,
624
786
  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
- },
787
+ (event) => streamBridge.onEvent(event),
662
788
  options
663
789
  );
664
790
 
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
- };
791
+ return streamBridge.mergeResult(result, started);
673
792
  }
674
793
 
675
794
  async streamEvents(model, promptOrMessages, onEvent, options = {}) {
@@ -694,7 +813,10 @@ class BrowserJsClient {
694
813
  max_tokens: options.maxTokens,
695
814
  temperature: options.temperature,
696
815
  top_p: options.topP,
697
- stream: true
816
+ stream: true,
817
+ stream_options: {
818
+ include_usage: true
819
+ }
698
820
  })
699
821
  });
700
822
 
@@ -711,6 +833,12 @@ class BrowserJsClient {
711
833
  let responseModel = model;
712
834
  let aggregate = "";
713
835
  let finishReason;
836
+ let usage = {
837
+ promptTokens: 0,
838
+ completionTokens: 0,
839
+ totalTokens: 0
840
+ };
841
+ let usageAvailable = false;
714
842
 
715
843
  try {
716
844
  for await (const block of iterateSse(response)) {
@@ -751,6 +879,10 @@ class BrowserJsClient {
751
879
  if (delta.finishReason) {
752
880
  finishReason = delta.finishReason;
753
881
  }
882
+ if (chunk?.usage && typeof chunk.usage === "object") {
883
+ usage = toUsage(chunk.usage);
884
+ usageAvailable = true;
885
+ }
754
886
 
755
887
  onEvent({ eventType: "delta", delta });
756
888
  }
@@ -771,11 +903,9 @@ class BrowserJsClient {
771
903
  content: aggregate,
772
904
  finishReason,
773
905
  usage: {
774
- promptTokens: 0,
775
- completionTokens: 0,
776
- totalTokens: 0
906
+ ...usage
777
907
  },
778
- usageAvailable: false,
908
+ usageAvailable,
779
909
  latencyMs,
780
910
  raw: undefined,
781
911
  healed: undefined,
@@ -826,9 +956,17 @@ class BrowserJsClient {
826
956
  nodes: {}
827
957
  };
828
958
 
959
+ const workflowStarted = performance.now();
960
+ let workflowTtftMs = 0;
829
961
  let pointer = doc.entry_node;
830
962
  let output;
831
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 = [];
832
970
 
833
971
  while (typeof pointer === "string" && pointer.length > 0) {
834
972
  iterations += 1;
@@ -843,6 +981,11 @@ class BrowserJsClient {
843
981
 
844
982
  const nodeType = node.node_type ?? {};
845
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;
846
989
  events.push({ stepId: node.id, stepType: nodeTypeName, status: "started" });
847
990
 
848
991
  if (nodeType.llm_call) {
@@ -874,10 +1017,57 @@ class BrowserJsClient {
874
1017
  promptOrMessages = history;
875
1018
  }
876
1019
 
877
- const completion = await this.complete(model, promptOrMessages, {
878
- temperature: llm.temperature
879
- });
880
- 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);
881
1071
  const validationError = schemaValidationError(llmOutputSchema(node), parsedOutput);
882
1072
  if (validationError !== null) {
883
1073
  throw runtimeError(
@@ -886,7 +1076,7 @@ class BrowserJsClient {
886
1076
  }
887
1077
  graphContext.nodes[node.id] = {
888
1078
  output: parsedOutput,
889
- raw: completion.content ?? ""
1079
+ raw: rawContent
890
1080
  };
891
1081
  output = parsedOutput;
892
1082
 
@@ -901,15 +1091,22 @@ class BrowserJsClient {
901
1091
  pointer = matched?.target ?? switchSpec.default ?? "";
902
1092
  } else if (nodeType.custom_worker) {
903
1093
  const handler = nodeType.custom_worker.handler ?? "custom_worker";
904
- 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];
905
1100
  if (typeof fn !== "function") {
906
1101
  throw runtimeError(
907
- `custom_worker node '${node.id}' requires workflowOptions.functions['${handler}']`
1102
+ `custom_worker node '${node.id}' requires workflowOptions.functions['${lookupKey}']`
908
1103
  );
909
1104
  }
910
1105
  const workerOutput = await fn(
911
1106
  {
912
1107
  handler,
1108
+ handler_file: handlerFile,
1109
+ handler_lookup_key: lookupKey,
913
1110
  payload: interpolatePathValue(node.config?.payload ?? null, graphContext),
914
1111
  nodeId: node.id
915
1112
  },
@@ -926,13 +1123,92 @@ class BrowserJsClient {
926
1123
  }
927
1124
 
928
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
+ }
929
1187
  }
930
1188
 
931
1189
  return {
932
- 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,
933
1210
  context: graphContext,
934
- output,
935
- events
1211
+ status: "ok"
936
1212
  };
937
1213
  }
938
1214
 
@@ -1021,10 +1297,22 @@ class BrowserJsClient {
1021
1297
  }
1022
1298
 
1023
1299
  return {
1024
- 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,
1025
1314
  context,
1026
- output,
1027
- events
1315
+ status: "ok"
1028
1316
  };
1029
1317
  }
1030
1318
 
@@ -1043,7 +1331,7 @@ async function loadRustModule() {
1043
1331
  try {
1044
1332
  const moduleValue = await import("./pkg/simple_agents_wasm.js");
1045
1333
  const wasmUrl = new URL("./pkg/simple_agents_wasm_bg.wasm", import.meta.url);
1046
- await moduleValue.default(wasmUrl);
1334
+ await moduleValue.default({ module_or_path: wasmUrl });
1047
1335
  return moduleValue;
1048
1336
  } catch {
1049
1337
  return null;
@@ -1108,56 +1396,17 @@ export class Client {
1108
1396
  async stream(model, promptOrMessages, onChunk, options = {}) {
1109
1397
  const rust = await this.ensureBackend();
1110
1398
  if (rust) {
1111
- let aggregate = "";
1112
- let finalId = "";
1113
- let finalModel = model;
1114
- let finalFinishReason;
1115
1399
  const started = performance.now();
1400
+ const streamBridge = createStreamEventBridge(model, onChunk);
1116
1401
 
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);
1402
+ const result = await rust.streamEvents(
1403
+ model,
1404
+ promptOrMessages,
1405
+ (event) => streamBridge.onEvent(event),
1406
+ options
1407
+ );
1152
1408
 
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
- };
1409
+ return streamBridge.mergeResult(result, started);
1161
1410
  }
1162
1411
 
1163
1412
  return this.fallbackClient.stream(model, promptOrMessages, onChunk, options);
@@ -1174,17 +1423,25 @@ export class Client {
1174
1423
  async runWorkflowYamlString(yamlText, workflowInput, workflowOptions) {
1175
1424
  const rust = await this.ensureBackend();
1176
1425
  if (rust) {
1177
- return rust.runWorkflowYamlString(yamlText, workflowInput, workflowOptions);
1426
+ const result = await rust.runWorkflowYamlString(yamlText, workflowInput, workflowOptions);
1427
+ return assertWorkflowResultShape(normalizeWorkflowResult(result));
1178
1428
  }
1179
- 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));
1180
1435
  }
1181
1436
 
1182
1437
  async runWorkflowYaml(workflowPath, workflowInput) {
1183
1438
  const rust = await this.ensureBackend();
1184
1439
  if (rust) {
1185
- return rust.runWorkflowYaml(workflowPath, workflowInput);
1440
+ const result = await rust.runWorkflowYaml(workflowPath, workflowInput);
1441
+ return assertWorkflowResultShape(normalizeWorkflowResult(result));
1186
1442
  }
1187
- return this.fallbackClient.runWorkflowYaml(workflowPath, workflowInput);
1443
+ const result = await this.fallbackClient.runWorkflowYaml(workflowPath, workflowInput);
1444
+ return assertWorkflowResultShape(normalizeWorkflowResult(result));
1188
1445
  }
1189
1446
  }
1190
1447
 
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.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.31"
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)]
@@ -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"))