simple-agents-wasm 0.2.28

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.js ADDED
@@ -0,0 +1,734 @@
1
+ import { parse as parseYaml } from "yaml";
2
+
3
+ const DEFAULT_BASE_URLS = {
4
+ openai: "https://api.openai.com/v1",
5
+ openrouter: "https://openrouter.ai/api/v1"
6
+ };
7
+
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
+ function toMessages(promptOrMessages) {
17
+ if (typeof promptOrMessages === "string") {
18
+ const content = promptOrMessages.trim();
19
+ if (content.length === 0) {
20
+ throw configError("prompt cannot be empty");
21
+ }
22
+ return [{ role: "user", content }];
23
+ }
24
+
25
+ if (!Array.isArray(promptOrMessages) || promptOrMessages.length === 0) {
26
+ throw configError("messages must be a non-empty array");
27
+ }
28
+
29
+ return promptOrMessages;
30
+ }
31
+
32
+ function toUsage(usage) {
33
+ if (!usage || typeof usage !== "object") {
34
+ return {
35
+ promptTokens: 0,
36
+ completionTokens: 0,
37
+ totalTokens: 0
38
+ };
39
+ }
40
+
41
+ return {
42
+ promptTokens: usage.prompt_tokens ?? usage.promptTokens ?? 0,
43
+ completionTokens: usage.completion_tokens ?? usage.completionTokens ?? 0,
44
+ totalTokens: usage.total_tokens ?? usage.totalTokens ?? 0
45
+ };
46
+ }
47
+
48
+ function toToolCalls(toolCalls) {
49
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
50
+ return undefined;
51
+ }
52
+
53
+ return toolCalls
54
+ .filter((call) => call && typeof call === "object")
55
+ .map((call) => ({
56
+ id: call.id ?? "",
57
+ toolType: call.type ?? call.toolType ?? "function",
58
+ function: {
59
+ name: call.function?.name ?? "",
60
+ arguments: call.function?.arguments ?? ""
61
+ }
62
+ }));
63
+ }
64
+
65
+ function normalizeBaseUrl(baseUrl) {
66
+ return baseUrl.replace(/\/$/, "");
67
+ }
68
+
69
+ function interpolate(value, context) {
70
+ if (typeof value === "string") {
71
+ return value.replace(/{{\s*([^}]+)\s*}}/g, (_, key) => {
72
+ const token = String(key).trim();
73
+ const resolved = context[token];
74
+ if (resolved === null || resolved === undefined) {
75
+ return "";
76
+ }
77
+ if (typeof resolved === "string") {
78
+ return resolved;
79
+ }
80
+ return JSON.stringify(resolved);
81
+ });
82
+ }
83
+
84
+ if (Array.isArray(value)) {
85
+ return value.map((entry) => interpolate(entry, context));
86
+ }
87
+
88
+ if (value !== null && value !== undefined && typeof value === "object") {
89
+ const output = {};
90
+ for (const [key, nested] of Object.entries(value)) {
91
+ output[key] = interpolate(nested, context);
92
+ }
93
+ return output;
94
+ }
95
+
96
+ return value;
97
+ }
98
+
99
+ function evaluateCondition(condition, context) {
100
+ if (!condition || typeof condition !== "object") {
101
+ return false;
102
+ }
103
+
104
+ const left = interpolate(condition.left, context);
105
+ const right = interpolate(condition.right, context);
106
+
107
+ if (condition.operator === "eq") {
108
+ return left === right;
109
+ }
110
+ if (condition.operator === "ne") {
111
+ return left !== right;
112
+ }
113
+ if (condition.operator === "contains") {
114
+ return String(left).includes(String(right));
115
+ }
116
+
117
+ return false;
118
+ }
119
+
120
+ function parseWorkflow(yamlText) {
121
+ if (typeof yamlText !== "string" || yamlText.trim().length === 0) {
122
+ throw configError("yamlText must be a non-empty string");
123
+ }
124
+
125
+ const parsed = parseYaml(yamlText);
126
+ if (!parsed || typeof parsed !== "object") {
127
+ throw configError("workflow YAML must parse to an object");
128
+ }
129
+
130
+ if (!Array.isArray(parsed.steps)) {
131
+ throw configError("workflow YAML must contain a steps array");
132
+ }
133
+
134
+ return parsed;
135
+ }
136
+
137
+ function parseSseEventBlock(block) {
138
+ const lines = block.split("\n");
139
+ const dataLines = [];
140
+ for (const line of lines) {
141
+ if (line.startsWith("data:")) {
142
+ dataLines.push(line.slice(5).trimStart());
143
+ }
144
+ }
145
+
146
+ if (dataLines.length === 0) {
147
+ return null;
148
+ }
149
+
150
+ const payload = dataLines.join("\n");
151
+ if (payload === "[DONE]") {
152
+ return { done: true };
153
+ }
154
+
155
+ try {
156
+ return { done: false, json: JSON.parse(payload), raw: payload };
157
+ } catch {
158
+ return { done: false, raw: payload };
159
+ }
160
+ }
161
+
162
+ async function* iterateSse(response) {
163
+ if (!response.body) {
164
+ throw runtimeError("stream response had no body");
165
+ }
166
+
167
+ const reader = response.body.getReader();
168
+ const decoder = new TextDecoder();
169
+ let buffer = "";
170
+
171
+ while (true) {
172
+ const { value, done } = await reader.read();
173
+ if (done) {
174
+ break;
175
+ }
176
+
177
+ buffer += decoder.decode(value, { stream: true });
178
+ let delimiterIndex = buffer.indexOf("\n\n");
179
+ while (delimiterIndex !== -1) {
180
+ const block = buffer.slice(0, delimiterIndex).trim();
181
+ buffer = buffer.slice(delimiterIndex + 2);
182
+ if (block.length > 0) {
183
+ yield block;
184
+ }
185
+ delimiterIndex = buffer.indexOf("\n\n");
186
+ }
187
+ }
188
+
189
+ const trailing = buffer.trim();
190
+ if (trailing.length > 0) {
191
+ yield trailing;
192
+ }
193
+ }
194
+
195
+ class BrowserJsClient {
196
+ constructor(provider, config) {
197
+ if (provider !== "openai" && provider !== "openrouter") {
198
+ throw configError("provider must be 'openai' or 'openrouter' in wasm mode");
199
+ }
200
+
201
+ if (!config || typeof config !== "object") {
202
+ throw configError("config object is required");
203
+ }
204
+
205
+ if (typeof config.apiKey !== "string" || config.apiKey.trim() === "") {
206
+ throw configError("config.apiKey is required");
207
+ }
208
+
209
+ this.provider = provider;
210
+ this.baseUrl = normalizeBaseUrl(config.baseUrl ?? DEFAULT_BASE_URLS[provider] ?? "");
211
+ this.apiKey = config.apiKey;
212
+ this.fetchImpl = config.fetchImpl ?? globalThis.fetch;
213
+ this.headers = config.headers ?? {};
214
+
215
+ if (typeof this.fetchImpl !== "function") {
216
+ throw configError("fetch implementation is required");
217
+ }
218
+ if (!this.baseUrl) {
219
+ throw configError("baseUrl is required");
220
+ }
221
+ }
222
+
223
+ async complete(model, promptOrMessages, options = {}) {
224
+ if (typeof model !== "string" || model.trim() === "") {
225
+ throw configError("model cannot be empty");
226
+ }
227
+
228
+ const mode = options.mode ?? "standard";
229
+ if (mode === "healed_json" || mode === "schema") {
230
+ throw runtimeError(
231
+ "healed_json and schema modes are not supported in simple-agents-wasm yet"
232
+ );
233
+ }
234
+
235
+ const started = performance.now();
236
+ const messages = toMessages(promptOrMessages);
237
+ const response = await this.fetchImpl(`${this.baseUrl}/chat/completions`, {
238
+ method: "POST",
239
+ headers: {
240
+ "Content-Type": "application/json",
241
+ Authorization: `Bearer ${this.apiKey}`,
242
+ ...this.headers
243
+ },
244
+ body: JSON.stringify({
245
+ model,
246
+ messages,
247
+ max_tokens: options.maxTokens,
248
+ temperature: options.temperature,
249
+ top_p: options.topP,
250
+ stream: false
251
+ })
252
+ });
253
+
254
+ if (!response.ok) {
255
+ const body = await response.text();
256
+ throw runtimeError(`request failed (${response.status}): ${body.slice(0, 500)}`);
257
+ }
258
+
259
+ const data = await response.json();
260
+ const choice = data?.choices?.[0];
261
+ const latencyMs = Math.max(0, Math.round(performance.now() - started));
262
+
263
+ return {
264
+ id: data?.id ?? "",
265
+ model: data?.model ?? model,
266
+ role: choice?.message?.role ?? "assistant",
267
+ content: choice?.message?.content,
268
+ toolCalls: toToolCalls(choice?.message?.tool_calls),
269
+ finishReason: choice?.finish_reason,
270
+ usage: toUsage(data?.usage),
271
+ usageAvailable: Boolean(data?.usage),
272
+ latencyMs,
273
+ raw: JSON.stringify(data),
274
+ healed: undefined,
275
+ coerced: undefined
276
+ };
277
+ }
278
+
279
+ async stream(model, promptOrMessages, onChunk, options = {}) {
280
+ if (typeof onChunk !== "function") {
281
+ throw configError("onChunk callback is required");
282
+ }
283
+
284
+ let aggregate = "";
285
+ let finalId = "";
286
+ let finalModel = model;
287
+ let finalFinishReason;
288
+ const started = performance.now();
289
+
290
+ const result = await this.streamEvents(
291
+ model,
292
+ promptOrMessages,
293
+ (event) => {
294
+ if (event.eventType === "delta") {
295
+ const delta = event.delta;
296
+ if (!delta) {
297
+ return;
298
+ }
299
+
300
+ if (delta.id && finalId.length === 0) {
301
+ finalId = delta.id;
302
+ }
303
+ if (delta.model) {
304
+ finalModel = delta.model;
305
+ }
306
+ if (delta.content) {
307
+ aggregate += delta.content;
308
+ }
309
+ if (delta.finishReason) {
310
+ finalFinishReason = delta.finishReason;
311
+ }
312
+
313
+ onChunk({
314
+ id: delta.id,
315
+ model: delta.model,
316
+ content: delta.content,
317
+ finishReason: delta.finishReason,
318
+ raw: delta.raw
319
+ });
320
+ }
321
+
322
+ if (event.eventType === "error") {
323
+ onChunk({
324
+ id: finalId || "error",
325
+ model: finalModel,
326
+ error: event.error?.message ?? "stream error"
327
+ });
328
+ }
329
+ },
330
+ options
331
+ );
332
+
333
+ return {
334
+ ...result,
335
+ id: result.id || finalId,
336
+ model: result.model || finalModel,
337
+ content: result.content ?? aggregate,
338
+ finishReason: result.finishReason ?? finalFinishReason,
339
+ latencyMs: Math.max(0, Math.round(performance.now() - started))
340
+ };
341
+ }
342
+
343
+ async streamEvents(model, promptOrMessages, onEvent, options = {}) {
344
+ if (typeof model !== "string" || model.trim() === "") {
345
+ throw configError("model cannot be empty");
346
+ }
347
+ if (typeof onEvent !== "function") {
348
+ throw configError("onEvent callback is required");
349
+ }
350
+
351
+ const messages = toMessages(promptOrMessages);
352
+ const response = await this.fetchImpl(`${this.baseUrl}/chat/completions`, {
353
+ method: "POST",
354
+ headers: {
355
+ "Content-Type": "application/json",
356
+ Authorization: `Bearer ${this.apiKey}`,
357
+ ...this.headers
358
+ },
359
+ body: JSON.stringify({
360
+ model,
361
+ messages,
362
+ max_tokens: options.maxTokens,
363
+ temperature: options.temperature,
364
+ top_p: options.topP,
365
+ stream: true
366
+ })
367
+ });
368
+
369
+ if (!response.ok) {
370
+ const body = await response.text();
371
+ const message = `request failed (${response.status}): ${body.slice(0, 500)}`;
372
+ const errorEvent = { eventType: "error", error: { message } };
373
+ onEvent(errorEvent);
374
+ throw runtimeError(message);
375
+ }
376
+
377
+ const started = performance.now();
378
+ let responseId = "";
379
+ let responseModel = model;
380
+ let aggregate = "";
381
+ let finishReason;
382
+
383
+ try {
384
+ for await (const block of iterateSse(response)) {
385
+ const parsed = parseSseEventBlock(block);
386
+ if (!parsed) {
387
+ continue;
388
+ }
389
+
390
+ if (parsed.done) {
391
+ break;
392
+ }
393
+
394
+ if (!parsed.json) {
395
+ continue;
396
+ }
397
+
398
+ const chunk = parsed.json;
399
+ const choice = chunk?.choices?.[0];
400
+ const delta = {
401
+ id: chunk?.id ?? "",
402
+ model: chunk?.model ?? model,
403
+ index: choice?.index ?? 0,
404
+ role: choice?.delta?.role,
405
+ content: choice?.delta?.content,
406
+ finishReason: choice?.finish_reason,
407
+ raw: parsed.raw
408
+ };
409
+
410
+ if (!responseId && delta.id) {
411
+ responseId = delta.id;
412
+ }
413
+ if (delta.model) {
414
+ responseModel = delta.model;
415
+ }
416
+ if (delta.content) {
417
+ aggregate += delta.content;
418
+ }
419
+ if (delta.finishReason) {
420
+ finishReason = delta.finishReason;
421
+ }
422
+
423
+ onEvent({ eventType: "delta", delta });
424
+ }
425
+
426
+ onEvent({ eventType: "done" });
427
+ } catch (error) {
428
+ const message = error instanceof Error ? error.message : "stream parsing failed";
429
+ onEvent({ eventType: "error", error: { message } });
430
+ throw runtimeError(message);
431
+ }
432
+
433
+ const latencyMs = Math.max(0, Math.round(performance.now() - started));
434
+
435
+ return {
436
+ id: responseId,
437
+ model: responseModel,
438
+ role: "assistant",
439
+ content: aggregate,
440
+ finishReason,
441
+ usage: {
442
+ promptTokens: 0,
443
+ completionTokens: 0,
444
+ totalTokens: 0
445
+ },
446
+ usageAvailable: false,
447
+ latencyMs,
448
+ raw: undefined,
449
+ healed: undefined,
450
+ coerced: undefined
451
+ };
452
+ }
453
+
454
+ async runWorkflowYamlString() {
455
+ const yamlText = arguments[0];
456
+ const workflowInput = arguments[1] ?? {};
457
+ const workflowOptions = arguments[2] ?? {};
458
+
459
+ const doc = parseWorkflow(yamlText);
460
+ if (workflowInput === null || typeof workflowInput !== "object") {
461
+ throw configError("workflowInput must be an object");
462
+ }
463
+
464
+ const context = { ...workflowInput };
465
+ const events = [];
466
+ const functions =
467
+ workflowOptions && typeof workflowOptions.functions === "object"
468
+ ? workflowOptions.functions
469
+ : {};
470
+
471
+ const indexById = new Map();
472
+ doc.steps.forEach((step, index) => {
473
+ if (!step || typeof step !== "object") {
474
+ throw configError(`workflow step at index ${index} must be an object`);
475
+ }
476
+ if (!step.id || !step.type) {
477
+ throw configError(`workflow step at index ${index} requires id and type`);
478
+ }
479
+ indexById.set(step.id, index);
480
+ });
481
+
482
+ let pointer = 0;
483
+ let output;
484
+ let iterations = 0;
485
+
486
+ while (pointer < doc.steps.length) {
487
+ iterations += 1;
488
+ if (iterations > 1000) {
489
+ throw runtimeError("workflow exceeded maximum step iterations");
490
+ }
491
+
492
+ const step = doc.steps[pointer];
493
+ events.push({ stepId: step.id, stepType: step.type, status: "started" });
494
+
495
+ if (step.type === "set") {
496
+ if (typeof step.key !== "string" || step.key.length === 0) {
497
+ throw configError(`set step '${step.id}' requires key`);
498
+ }
499
+ context[step.key] = interpolate(step.value, context);
500
+ } else if (step.type === "llm_call") {
501
+ const prompt = String(interpolate(step.prompt ?? "", context));
502
+ const model = step.model ?? doc.model ?? context.model;
503
+ if (typeof model !== "string" || model.trim().length === 0) {
504
+ throw configError(`llm_call step '${step.id}' requires a model (step.model, workflow model, or workflowInput.model)`);
505
+ }
506
+ const completion = await this.complete(model, prompt, {
507
+ temperature: step.temperature
508
+ });
509
+ context[step.id] = completion.content ?? "";
510
+ } else if (step.type === "if") {
511
+ const matched = evaluateCondition(step.condition, context);
512
+ const targetId = matched ? step.then : step.else;
513
+ if (targetId) {
514
+ const jumpTo = indexById.get(targetId);
515
+ if (jumpTo === undefined) {
516
+ throw configError(`if step '${step.id}' points to unknown step '${targetId}'`);
517
+ }
518
+ events.push({ stepId: step.id, stepType: step.type, status: "completed" });
519
+ pointer = jumpTo;
520
+ continue;
521
+ }
522
+ } else if (step.type === "call_function") {
523
+ if (typeof step.function !== "string" || step.function.length === 0) {
524
+ throw configError(`call_function step '${step.id}' requires function`);
525
+ }
526
+
527
+ const fn = functions[step.function];
528
+ if (typeof fn !== "function") {
529
+ throw configError(`call_function step '${step.id}' references unknown function '${step.function}'`);
530
+ }
531
+
532
+ const args = interpolate(step.args ?? {}, context);
533
+ context[step.id] = fn(args, context);
534
+ } else if (step.type === "output") {
535
+ output = interpolate(step.text ?? step.value ?? "", context);
536
+ context[step.id] = output;
537
+ } else {
538
+ throw configError(`unsupported step type '${step.type}' in simple-agents-wasm`);
539
+ }
540
+
541
+ events.push({ stepId: step.id, stepType: step.type, status: "completed" });
542
+
543
+ if (step.next) {
544
+ const jumpTo = indexById.get(step.next);
545
+ if (jumpTo === undefined) {
546
+ throw configError(`step '${step.id}' points to unknown next step '${step.next}'`);
547
+ }
548
+ pointer = jumpTo;
549
+ continue;
550
+ }
551
+
552
+ pointer += 1;
553
+ }
554
+
555
+ return {
556
+ status: "ok",
557
+ context,
558
+ output,
559
+ events
560
+ };
561
+ }
562
+
563
+ async runWorkflowYaml(workflowPath) {
564
+ throw runtimeError(
565
+ `workflow file paths are not supported in browser runtime: ${workflowPath}`
566
+ );
567
+ }
568
+ }
569
+
570
+ let rustModulePromise;
571
+
572
+ async function loadRustModule() {
573
+ if (!rustModulePromise) {
574
+ rustModulePromise = (async () => {
575
+ try {
576
+ const moduleValue = await import("./pkg/simple_agents_wasm.js");
577
+ const wasmUrl = new URL("./pkg/simple_agents_wasm_bg.wasm", import.meta.url);
578
+ await moduleValue.default(wasmUrl);
579
+ return moduleValue;
580
+ } catch {
581
+ return null;
582
+ }
583
+ })();
584
+ }
585
+
586
+ return rustModulePromise;
587
+ }
588
+
589
+ export class Client {
590
+ constructor(provider, config) {
591
+ this.fallbackClient = new BrowserJsClient(provider, config);
592
+ this.provider = provider;
593
+ this.config = config;
594
+ this.rustClient = null;
595
+ this.readyPromise = null;
596
+ }
597
+
598
+ async ensureBackend() {
599
+ if (this.rustClient) {
600
+ return this.rustClient;
601
+ }
602
+ if (this.readyPromise) {
603
+ return this.readyPromise;
604
+ }
605
+
606
+ this.readyPromise = (async () => {
607
+ if (this.config.fetchImpl && this.config.fetchImpl !== globalThis.fetch) {
608
+ return null;
609
+ }
610
+
611
+ const moduleValue = await loadRustModule();
612
+ if (!moduleValue || typeof moduleValue.WasmClient !== "function") {
613
+ return null;
614
+ }
615
+
616
+ try {
617
+ const client = new moduleValue.WasmClient(this.provider, {
618
+ apiKey: this.config.apiKey,
619
+ baseUrl: this.config.baseUrl,
620
+ headers: this.config.headers
621
+ });
622
+ this.rustClient = client;
623
+ return client;
624
+ } catch {
625
+ return null;
626
+ }
627
+ })();
628
+
629
+ return this.readyPromise;
630
+ }
631
+
632
+ async complete(model, promptOrMessages, options = {}) {
633
+ const rust = await this.ensureBackend();
634
+ if (rust) {
635
+ return rust.complete(model, promptOrMessages, options);
636
+ }
637
+ return this.fallbackClient.complete(model, promptOrMessages, options);
638
+ }
639
+
640
+ async stream(model, promptOrMessages, onChunk, options = {}) {
641
+ const rust = await this.ensureBackend();
642
+ if (rust) {
643
+ let aggregate = "";
644
+ let finalId = "";
645
+ let finalModel = model;
646
+ let finalFinishReason;
647
+ const started = performance.now();
648
+
649
+ const result = await rust.streamEvents(model, promptOrMessages, (event) => {
650
+ if (event.eventType === "delta") {
651
+ const delta = event.delta;
652
+ if (!delta) {
653
+ return;
654
+ }
655
+ if (!finalId && delta.id) {
656
+ finalId = delta.id;
657
+ }
658
+ if (delta.model) {
659
+ finalModel = delta.model;
660
+ }
661
+ if (delta.content) {
662
+ aggregate += delta.content;
663
+ }
664
+ if (delta.finishReason) {
665
+ finalFinishReason = delta.finishReason;
666
+ }
667
+ onChunk({
668
+ id: delta.id,
669
+ model: delta.model,
670
+ content: delta.content,
671
+ finishReason: delta.finishReason,
672
+ raw: delta.raw
673
+ });
674
+ }
675
+
676
+ if (event.eventType === "error") {
677
+ onChunk({
678
+ id: finalId || "error",
679
+ model: finalModel,
680
+ error: event.error?.message ?? "stream error"
681
+ });
682
+ }
683
+ }, options);
684
+
685
+ return {
686
+ ...result,
687
+ id: result.id || finalId,
688
+ model: result.model || finalModel,
689
+ content: result.content ?? aggregate,
690
+ finishReason: result.finishReason ?? finalFinishReason,
691
+ latencyMs: Math.max(0, Math.round(performance.now() - started))
692
+ };
693
+ }
694
+
695
+ return this.fallbackClient.stream(model, promptOrMessages, onChunk, options);
696
+ }
697
+
698
+ async streamEvents(model, promptOrMessages, onEvent, options = {}) {
699
+ const rust = await this.ensureBackend();
700
+ if (rust) {
701
+ return rust.streamEvents(model, promptOrMessages, onEvent, options);
702
+ }
703
+ return this.fallbackClient.streamEvents(model, promptOrMessages, onEvent, options);
704
+ }
705
+
706
+ async runWorkflowYamlString(yamlText, workflowInput, workflowOptions) {
707
+ const rust = await this.ensureBackend();
708
+ if (rust) {
709
+ return rust.runWorkflowYamlString(yamlText, workflowInput, workflowOptions);
710
+ }
711
+ return this.fallbackClient.runWorkflowYamlString(yamlText, workflowInput, workflowOptions);
712
+ }
713
+
714
+ async runWorkflowYaml(workflowPath, workflowInput) {
715
+ const rust = await this.ensureBackend();
716
+ if (rust) {
717
+ return rust.runWorkflowYaml(workflowPath, workflowInput);
718
+ }
719
+ return this.fallbackClient.runWorkflowYaml(workflowPath, workflowInput);
720
+ }
721
+ }
722
+
723
+ export async function hasRustBackend() {
724
+ const moduleValue = await loadRustModule();
725
+ if (!moduleValue || typeof moduleValue.supportsRustWasm !== "function") {
726
+ return false;
727
+ }
728
+
729
+ try {
730
+ return Boolean(moduleValue.supportsRustWasm());
731
+ } catch {
732
+ return false;
733
+ }
734
+ }