@funkai/agents 0.1.0

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.
Files changed (153) hide show
  1. package/.generated/req.txt +1 -0
  2. package/.turbo/turbo-build.log +21 -0
  3. package/.turbo/turbo-test$colon$coverage.log +109 -0
  4. package/.turbo/turbo-test.log +141 -0
  5. package/.turbo/turbo-typecheck.log +4 -0
  6. package/CHANGELOG.md +16 -0
  7. package/ISSUES.md +540 -0
  8. package/LICENSE +21 -0
  9. package/README.md +128 -0
  10. package/banner.svg +97 -0
  11. package/coverage/lcov-report/base.css +224 -0
  12. package/coverage/lcov-report/block-navigation.js +87 -0
  13. package/coverage/lcov-report/core/agents/base/agent.ts.html +1705 -0
  14. package/coverage/lcov-report/core/agents/base/index.html +146 -0
  15. package/coverage/lcov-report/core/agents/base/output.ts.html +256 -0
  16. package/coverage/lcov-report/core/agents/base/utils.ts.html +694 -0
  17. package/coverage/lcov-report/core/agents/flow/engine.ts.html +928 -0
  18. package/coverage/lcov-report/core/agents/flow/flow-agent.ts.html +1462 -0
  19. package/coverage/lcov-report/core/agents/flow/index.html +146 -0
  20. package/coverage/lcov-report/core/agents/flow/messages.ts.html +508 -0
  21. package/coverage/lcov-report/core/agents/flow/steps/factory.ts.html +1975 -0
  22. package/coverage/lcov-report/core/agents/flow/steps/index.html +116 -0
  23. package/coverage/lcov-report/core/index.html +131 -0
  24. package/coverage/lcov-report/core/logger.ts.html +541 -0
  25. package/coverage/lcov-report/core/models/providers/index.html +116 -0
  26. package/coverage/lcov-report/core/models/providers/openai.ts.html +337 -0
  27. package/coverage/lcov-report/core/provider/index.html +131 -0
  28. package/coverage/lcov-report/core/provider/provider.ts.html +346 -0
  29. package/coverage/lcov-report/core/provider/usage.ts.html +376 -0
  30. package/coverage/lcov-report/core/tool.ts.html +577 -0
  31. package/coverage/lcov-report/favicon.png +0 -0
  32. package/coverage/lcov-report/index.html +221 -0
  33. package/coverage/lcov-report/lib/hooks.ts.html +262 -0
  34. package/coverage/lcov-report/lib/index.html +161 -0
  35. package/coverage/lcov-report/lib/middleware.ts.html +274 -0
  36. package/coverage/lcov-report/lib/runnable.ts.html +151 -0
  37. package/coverage/lcov-report/lib/trace.ts.html +520 -0
  38. package/coverage/lcov-report/prettify.css +1 -0
  39. package/coverage/lcov-report/prettify.js +2 -0
  40. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  41. package/coverage/lcov-report/sorter.js +210 -0
  42. package/coverage/lcov-report/utils/attempt.ts.html +199 -0
  43. package/coverage/lcov-report/utils/error.ts.html +421 -0
  44. package/coverage/lcov-report/utils/index.html +176 -0
  45. package/coverage/lcov-report/utils/resolve.ts.html +208 -0
  46. package/coverage/lcov-report/utils/result.ts.html +538 -0
  47. package/coverage/lcov-report/utils/zod.ts.html +178 -0
  48. package/coverage/lcov.info +1566 -0
  49. package/dist/index.d.mts +2883 -0
  50. package/dist/index.d.mts.map +1 -0
  51. package/dist/index.mjs +2312 -0
  52. package/dist/index.mjs.map +1 -0
  53. package/docs/core/agent.md +231 -0
  54. package/docs/core/hooks.md +95 -0
  55. package/docs/core/overview.md +87 -0
  56. package/docs/core/step.md +279 -0
  57. package/docs/core/tools.md +98 -0
  58. package/docs/core/workflow.md +235 -0
  59. package/docs/guides/create-agent.md +224 -0
  60. package/docs/guides/create-tool.md +137 -0
  61. package/docs/guides/create-workflow.md +374 -0
  62. package/docs/overview.md +244 -0
  63. package/docs/provider/models.md +55 -0
  64. package/docs/provider/overview.md +106 -0
  65. package/docs/provider/usage.md +100 -0
  66. package/docs/research/experimental-context.md +167 -0
  67. package/docs/research/gap-analysis.md +86 -0
  68. package/docs/research/prepare-step-and-active-tools.md +138 -0
  69. package/docs/research/sub-agent-model.md +249 -0
  70. package/docs/troubleshooting.md +60 -0
  71. package/logo.svg +17 -0
  72. package/models.config.json +18 -0
  73. package/package.json +60 -0
  74. package/scripts/generate-models.ts +324 -0
  75. package/src/core/agents/base/agent.test.ts +1522 -0
  76. package/src/core/agents/base/agent.ts +547 -0
  77. package/src/core/agents/base/output.test.ts +93 -0
  78. package/src/core/agents/base/output.ts +57 -0
  79. package/src/core/agents/base/types.test-d.ts +69 -0
  80. package/src/core/agents/base/types.ts +503 -0
  81. package/src/core/agents/base/utils.test.ts +397 -0
  82. package/src/core/agents/base/utils.ts +197 -0
  83. package/src/core/agents/flow/engine.test.ts +452 -0
  84. package/src/core/agents/flow/engine.ts +281 -0
  85. package/src/core/agents/flow/flow-agent.test.ts +1027 -0
  86. package/src/core/agents/flow/flow-agent.ts +473 -0
  87. package/src/core/agents/flow/messages.test.ts +198 -0
  88. package/src/core/agents/flow/messages.ts +141 -0
  89. package/src/core/agents/flow/steps/agent.test.ts +280 -0
  90. package/src/core/agents/flow/steps/agent.ts +87 -0
  91. package/src/core/agents/flow/steps/all.test.ts +300 -0
  92. package/src/core/agents/flow/steps/all.ts +73 -0
  93. package/src/core/agents/flow/steps/builder.ts +124 -0
  94. package/src/core/agents/flow/steps/each.test.ts +257 -0
  95. package/src/core/agents/flow/steps/each.ts +61 -0
  96. package/src/core/agents/flow/steps/factory.test-d.ts +50 -0
  97. package/src/core/agents/flow/steps/factory.test.ts +1025 -0
  98. package/src/core/agents/flow/steps/factory.ts +645 -0
  99. package/src/core/agents/flow/steps/map.test.ts +273 -0
  100. package/src/core/agents/flow/steps/map.ts +75 -0
  101. package/src/core/agents/flow/steps/race.test.ts +290 -0
  102. package/src/core/agents/flow/steps/race.ts +59 -0
  103. package/src/core/agents/flow/steps/reduce.test.ts +310 -0
  104. package/src/core/agents/flow/steps/reduce.ts +73 -0
  105. package/src/core/agents/flow/steps/result.ts +27 -0
  106. package/src/core/agents/flow/steps/step.test.ts +402 -0
  107. package/src/core/agents/flow/steps/step.ts +51 -0
  108. package/src/core/agents/flow/steps/while.test.ts +283 -0
  109. package/src/core/agents/flow/steps/while.ts +75 -0
  110. package/src/core/agents/flow/types.ts +348 -0
  111. package/src/core/logger.test.ts +163 -0
  112. package/src/core/logger.ts +152 -0
  113. package/src/core/models/index.test.ts +137 -0
  114. package/src/core/models/index.ts +152 -0
  115. package/src/core/models/providers/openai.ts +84 -0
  116. package/src/core/provider/provider.test.ts +128 -0
  117. package/src/core/provider/provider.ts +99 -0
  118. package/src/core/provider/types.ts +98 -0
  119. package/src/core/provider/usage.test.ts +304 -0
  120. package/src/core/provider/usage.ts +97 -0
  121. package/src/core/tool.test.ts +65 -0
  122. package/src/core/tool.ts +164 -0
  123. package/src/core/types.ts +66 -0
  124. package/src/index.ts +95 -0
  125. package/src/lib/context.test.ts +86 -0
  126. package/src/lib/context.ts +49 -0
  127. package/src/lib/hooks.test.ts +102 -0
  128. package/src/lib/hooks.ts +59 -0
  129. package/src/lib/middleware.test.ts +122 -0
  130. package/src/lib/middleware.ts +63 -0
  131. package/src/lib/runnable.test.ts +41 -0
  132. package/src/lib/runnable.ts +22 -0
  133. package/src/lib/trace.test.ts +291 -0
  134. package/src/lib/trace.ts +145 -0
  135. package/src/models/index.ts +123 -0
  136. package/src/models/providers/index.ts +15 -0
  137. package/src/models/providers/openai.ts +84 -0
  138. package/src/testing/context.ts +32 -0
  139. package/src/testing/index.ts +2 -0
  140. package/src/testing/logger.ts +19 -0
  141. package/src/utils/attempt.test.ts +127 -0
  142. package/src/utils/attempt.ts +38 -0
  143. package/src/utils/error.test.ts +179 -0
  144. package/src/utils/error.ts +112 -0
  145. package/src/utils/resolve.test.ts +38 -0
  146. package/src/utils/resolve.ts +41 -0
  147. package/src/utils/result.test.ts +79 -0
  148. package/src/utils/result.ts +151 -0
  149. package/src/utils/zod.test.ts +69 -0
  150. package/src/utils/zod.ts +31 -0
  151. package/tsconfig.json +25 -0
  152. package/tsdown.config.ts +15 -0
  153. package/vitest.config.ts +46 -0
@@ -0,0 +1,273 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { createStepBuilder } from "@/core/agents/flow/steps/factory.js";
4
+ import { createMockCtx } from "@/testing/index.js";
5
+
6
+ describe("map()", () => {
7
+ it("maps items in parallel via Promise.all by default", async () => {
8
+ const ctx = createMockCtx();
9
+ const $ = createStepBuilder({ ctx });
10
+
11
+ const result = await $.map({
12
+ id: "map-all",
13
+ input: [1, 2, 3],
14
+ execute: async ({ item }) => item * 2,
15
+ });
16
+
17
+ expect(result.ok).toBe(true);
18
+ if (!result.ok) {
19
+ return;
20
+ }
21
+ expect(result.value).toEqual([2, 4, 6]);
22
+ expect(result.step.type).toBe("map");
23
+ });
24
+
25
+ it("respects concurrency limit", async () => {
26
+ const ctx = createMockCtx();
27
+ const $ = createStepBuilder({ ctx });
28
+ const state = { maxConcurrent: 0, current: 0 };
29
+
30
+ await $.map({
31
+ id: "map-limited",
32
+ input: [1, 2, 3, 4, 5],
33
+ concurrency: 2,
34
+ execute: async ({ item }) => {
35
+ state.current++;
36
+ state.maxConcurrent = Math.max(state.maxConcurrent, state.current);
37
+ await new Promise((r) => setTimeout(r, 10));
38
+ state.current--;
39
+ return item;
40
+ },
41
+ });
42
+
43
+ expect(state.maxConcurrent).toBeLessThanOrEqual(2);
44
+ });
45
+
46
+ it("returns results in input order regardless of completion order", async () => {
47
+ const ctx = createMockCtx();
48
+ const $ = createStepBuilder({ ctx });
49
+
50
+ const result = await $.map({
51
+ id: "map-order",
52
+ input: [3, 1, 2],
53
+ concurrency: 2,
54
+ execute: async ({ item }) => {
55
+ await new Promise((r) => setTimeout(r, item * 5));
56
+ return item * 10;
57
+ },
58
+ });
59
+
60
+ expect(result.ok).toBe(true);
61
+ if (!result.ok) {
62
+ return;
63
+ }
64
+ expect(result.value).toEqual([30, 10, 20]);
65
+ });
66
+
67
+ it("handles empty input array", async () => {
68
+ const ctx = createMockCtx();
69
+ const $ = createStepBuilder({ ctx });
70
+
71
+ const result = await $.map({
72
+ id: "map-empty",
73
+ input: [],
74
+ execute: async () => "should not be called",
75
+ });
76
+
77
+ expect(result.ok).toBe(true);
78
+ if (!result.ok) {
79
+ return;
80
+ }
81
+ expect(result.value).toEqual([]);
82
+ });
83
+
84
+ it("handles single-item input", async () => {
85
+ const ctx = createMockCtx();
86
+ const $ = createStepBuilder({ ctx });
87
+
88
+ const result = await $.map({
89
+ id: "map-single",
90
+ input: ["only"],
91
+ execute: async ({ item }) => item.toUpperCase(),
92
+ });
93
+
94
+ expect(result.ok).toBe(true);
95
+ if (!result.ok) {
96
+ return;
97
+ }
98
+ expect(result.value).toEqual(["ONLY"]);
99
+ });
100
+
101
+ it("passes index to execute callback", async () => {
102
+ const ctx = createMockCtx();
103
+ const $ = createStepBuilder({ ctx });
104
+ const indices: number[] = [];
105
+
106
+ await $.map({
107
+ id: "map-index",
108
+ input: ["a", "b", "c"],
109
+ execute: async ({ index }) => {
110
+ indices.push(index);
111
+ return index;
112
+ },
113
+ });
114
+
115
+ expect(indices).toContain(0);
116
+ expect(indices).toContain(1);
117
+ expect(indices).toContain(2);
118
+ });
119
+
120
+ it("returns ok: false when execute throws", async () => {
121
+ const ctx = createMockCtx();
122
+ const $ = createStepBuilder({ ctx });
123
+
124
+ const result = await $.map({
125
+ id: "map-err",
126
+ input: [1, 2, 3],
127
+ execute: async ({ item }) => {
128
+ if (item === 2) {
129
+ throw new Error("bad item");
130
+ }
131
+ return item;
132
+ },
133
+ });
134
+
135
+ expect(result.ok).toBe(false);
136
+ if (result.ok) {
137
+ return;
138
+ }
139
+ expect(result.error.message).toBe("bad item");
140
+ expect(result.error.stepId).toBe("map-err");
141
+ });
142
+
143
+ it("records input in trace", async () => {
144
+ const ctx = createMockCtx();
145
+ const $ = createStepBuilder({ ctx });
146
+
147
+ await $.map({
148
+ id: "map-trace",
149
+ input: [10, 20],
150
+ execute: async ({ item }) => item,
151
+ });
152
+
153
+ const traceEntry = ctx.trace[0];
154
+ if (traceEntry === undefined) {
155
+ throw new Error("Expected trace entry");
156
+ }
157
+ expect(traceEntry.input).toEqual([10, 20]);
158
+ expect(traceEntry.type).toBe("map");
159
+ });
160
+
161
+ it("fires onStart and onFinish hooks", async () => {
162
+ const order: string[] = [];
163
+ const ctx = createMockCtx();
164
+ const $ = createStepBuilder({ ctx });
165
+
166
+ await $.map({
167
+ id: "map-hooks",
168
+ input: [1, 2],
169
+ onStart: () => {
170
+ order.push("onStart");
171
+ },
172
+ execute: async ({ item }) => {
173
+ order.push(`execute:${item}`);
174
+ return item;
175
+ },
176
+ onFinish: () => {
177
+ order.push("onFinish");
178
+ },
179
+ });
180
+
181
+ expect(order[0]).toBe("onStart");
182
+ expect(order[order.length - 1]).toBe("onFinish");
183
+ });
184
+
185
+ it("fires onError hook on failure", async () => {
186
+ const onError = vi.fn();
187
+ const ctx = createMockCtx();
188
+ const $ = createStepBuilder({ ctx });
189
+
190
+ await $.map({
191
+ id: "map-onerror",
192
+ input: [1],
193
+ execute: async () => {
194
+ throw new Error("map failure");
195
+ },
196
+ onError,
197
+ });
198
+
199
+ expect(onError).toHaveBeenCalledTimes(1);
200
+ expect(onError).toHaveBeenCalledWith(
201
+ expect.objectContaining({
202
+ id: "map-onerror",
203
+ error: expect.any(Error),
204
+ }),
205
+ );
206
+ });
207
+
208
+ it("onFinish receives the result array", async () => {
209
+ const onFinish = vi.fn();
210
+ const ctx = createMockCtx();
211
+ const $ = createStepBuilder({ ctx });
212
+
213
+ await $.map({
214
+ id: "map-finish-result",
215
+ input: [1, 2, 3],
216
+ execute: async ({ item }) => item * 2,
217
+ onFinish,
218
+ });
219
+
220
+ expect(onFinish).toHaveBeenCalledWith(
221
+ expect.objectContaining({
222
+ id: "map-finish-result",
223
+ result: [2, 4, 6],
224
+ duration: expect.any(Number),
225
+ }),
226
+ );
227
+ });
228
+
229
+ it("provides child $ for nested operations", async () => {
230
+ const ctx = createMockCtx();
231
+ const $$ = createStepBuilder({ ctx });
232
+
233
+ await $$.map({
234
+ id: "map-nested",
235
+ input: [1],
236
+ execute: async ({ item, $ }) => {
237
+ const inner = await $.step({
238
+ id: "inner",
239
+ execute: async () => item + 10,
240
+ });
241
+ if (!inner.ok) {
242
+ throw new Error("inner step failed");
243
+ }
244
+ return inner.value;
245
+ },
246
+ });
247
+
248
+ const traceEntry = ctx.trace[0];
249
+ if (traceEntry === undefined) {
250
+ throw new Error("Expected trace entry");
251
+ }
252
+ expect(traceEntry.children).toHaveLength(1);
253
+ });
254
+
255
+ it("handles concurrency of 1 (sequential)", async () => {
256
+ const ctx = createMockCtx();
257
+ const $ = createStepBuilder({ ctx });
258
+ const order: number[] = [];
259
+
260
+ await $.map({
261
+ id: "map-seq",
262
+ input: [1, 2, 3],
263
+ concurrency: 1,
264
+ execute: async ({ item }) => {
265
+ order.push(item);
266
+ await new Promise((r) => setTimeout(r, 5));
267
+ return item;
268
+ },
269
+ });
270
+
271
+ expect(order).toEqual([1, 2, 3]);
272
+ });
273
+ });
@@ -0,0 +1,75 @@
1
+ import type { StepBuilder } from "@/core/agents/flow/steps/builder.js";
2
+
3
+ /**
4
+ * Configuration for `$.map()` — parallel map with optional concurrency limit.
5
+ *
6
+ * Each item is processed as a tracked operation. All run concurrently
7
+ * (up to `concurrency` limit). Returns results in input order.
8
+ *
9
+ * @typeParam T - Input item type.
10
+ * @typeParam R - Output item type.
11
+ */
12
+ export interface MapConfig<T, R> {
13
+ /**
14
+ * Unique step identifier.
15
+ *
16
+ * Appears in the execution trace. Individual iterations appear
17
+ * as children of this entry.
18
+ */
19
+ id: string;
20
+
21
+ /**
22
+ * Array of items to process.
23
+ *
24
+ * Each item is passed to the `execute` callback along with its index.
25
+ */
26
+ input: readonly T[];
27
+
28
+ /**
29
+ * Maximum number of parallel executions.
30
+ *
31
+ * When set, limits how many `execute` callbacks run concurrently.
32
+ * Results are still returned in input order regardless.
33
+ *
34
+ * @default Infinity
35
+ */
36
+ concurrency?: number;
37
+
38
+ /**
39
+ * Process a single item.
40
+ *
41
+ * @param params - Execution parameters.
42
+ * @param params.item - The current item from the input array.
43
+ * @param params.index - The item's zero-based index in the input array.
44
+ * @param params.$ - The step builder for nesting further operations.
45
+ * @returns The processed result for this item.
46
+ */
47
+ execute: (params: { item: T; index: number; $: StepBuilder }) => Promise<R>;
48
+
49
+ /**
50
+ * Hook: fires when the map operation starts.
51
+ *
52
+ * @param event - Event containing the step id.
53
+ * @param event.id - The step's unique identifier.
54
+ */
55
+ onStart?: (event: { id: string }) => void | Promise<void>;
56
+
57
+ /**
58
+ * Hook: fires when all items are processed.
59
+ *
60
+ * @param event - Event containing the step id, results, and duration.
61
+ * @param event.id - The step's unique identifier.
62
+ * @param event.result - Array of results in input order.
63
+ * @param event.duration - Wall-clock time in milliseconds.
64
+ */
65
+ onFinish?: (event: { id: string; result: R[]; duration: number }) => void | Promise<void>;
66
+
67
+ /**
68
+ * Hook: fires if the map operation encounters an error.
69
+ *
70
+ * @param event - Event containing the step id and error.
71
+ * @param event.id - The step's unique identifier.
72
+ * @param event.error - The error that occurred.
73
+ */
74
+ onError?: (event: { id: string; error: Error }) => void | Promise<void>;
75
+ }
@@ -0,0 +1,290 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { createStepBuilder } from "@/core/agents/flow/steps/factory.js";
4
+ import { createMockCtx } from "@/testing/index.js";
5
+
6
+ describe("race()", () => {
7
+ it("returns first resolved value", async () => {
8
+ const ctx = createMockCtx();
9
+ const $ = createStepBuilder({ ctx });
10
+
11
+ const result = await $.race({
12
+ id: "race-first",
13
+ entries: [
14
+ () => new Promise((r) => setTimeout(() => r("slow"), 50)),
15
+ () => Promise.resolve("fast"),
16
+ ],
17
+ });
18
+
19
+ expect(result.ok).toBe(true);
20
+ if (!result.ok) {
21
+ return;
22
+ }
23
+ expect(result.value).toBe("fast");
24
+ expect(result.step.type).toBe("race");
25
+ });
26
+
27
+ it("cancels losing entries via abort signal", async () => {
28
+ const ctx = createMockCtx();
29
+ const $ = createStepBuilder({ ctx });
30
+ const signals: { loser: AbortSignal | undefined } = { loser: undefined };
31
+
32
+ const result = await $.race({
33
+ id: "race-cancel",
34
+ entries: [
35
+ () => Promise.resolve("winner"),
36
+ (signal) => {
37
+ signals.loser = signal;
38
+ return new Promise((r) => setTimeout(() => r("loser"), 500));
39
+ },
40
+ ],
41
+ });
42
+
43
+ expect(result.ok).toBe(true);
44
+ if (!result.ok) {
45
+ return;
46
+ }
47
+ expect(result.value).toBe("winner");
48
+ if (signals.loser === undefined) {
49
+ throw new Error("Expected loser signal");
50
+ }
51
+ expect(signals.loser.aborted).toBe(true);
52
+ });
53
+
54
+ it("passes abort signal to all entry factories", async () => {
55
+ const ctx = createMockCtx();
56
+ const $ = createStepBuilder({ ctx });
57
+ const receivedSignals: AbortSignal[] = [];
58
+
59
+ await $.race({
60
+ id: "race-signals",
61
+ entries: [
62
+ (signal) => {
63
+ receivedSignals.push(signal);
64
+ return Promise.resolve("a");
65
+ },
66
+ (signal) => {
67
+ receivedSignals.push(signal);
68
+ return Promise.resolve("b");
69
+ },
70
+ ],
71
+ });
72
+
73
+ expect(receivedSignals).toHaveLength(2);
74
+ expect(receivedSignals[0]).toBeInstanceOf(AbortSignal);
75
+ expect(receivedSignals[1]).toBeInstanceOf(AbortSignal);
76
+ });
77
+
78
+ it("all entries share the same abort signal", async () => {
79
+ const ctx = createMockCtx();
80
+ const $ = createStepBuilder({ ctx });
81
+ const receivedSignals: AbortSignal[] = [];
82
+
83
+ await $.race({
84
+ id: "race-shared-signal",
85
+ entries: [
86
+ (signal) => {
87
+ receivedSignals.push(signal);
88
+ return Promise.resolve("first");
89
+ },
90
+ (signal) => {
91
+ receivedSignals.push(signal);
92
+ return new Promise((r) => setTimeout(() => r("second"), 100));
93
+ },
94
+ ],
95
+ });
96
+
97
+ expect(receivedSignals).toHaveLength(2);
98
+ expect(receivedSignals[0]).toBe(receivedSignals[1]);
99
+ });
100
+
101
+ it("returns ok: false when all entries reject", async () => {
102
+ const ctx = createMockCtx();
103
+ const $ = createStepBuilder({ ctx });
104
+
105
+ const result = await $.race({
106
+ id: "race-all-fail",
107
+ entries: [
108
+ () => Promise.reject(new Error("fail-1")),
109
+ () => Promise.reject(new Error("fail-2")),
110
+ ],
111
+ });
112
+
113
+ // Promise.race rejects with the first rejection
114
+ expect(result.ok).toBe(false);
115
+ if (result.ok) {
116
+ return;
117
+ }
118
+ expect(result.error.stepId).toBe("race-all-fail");
119
+ });
120
+
121
+ it("handles single entry", async () => {
122
+ const ctx = createMockCtx();
123
+ const $ = createStepBuilder({ ctx });
124
+
125
+ const result = await $.race({
126
+ id: "race-single",
127
+ entries: [() => Promise.resolve("only")],
128
+ });
129
+
130
+ expect(result.ok).toBe(true);
131
+ if (!result.ok) {
132
+ return;
133
+ }
134
+ expect(result.value).toBe("only");
135
+ });
136
+
137
+ it("fires onStart and onFinish hooks", async () => {
138
+ const order: string[] = [];
139
+ const ctx = createMockCtx();
140
+ const $ = createStepBuilder({ ctx });
141
+
142
+ await $.race({
143
+ id: "race-hooks",
144
+ entries: [() => Promise.resolve("done")],
145
+ onStart: () => {
146
+ order.push("onStart");
147
+ },
148
+ onFinish: () => {
149
+ order.push("onFinish");
150
+ },
151
+ });
152
+
153
+ expect(order).toEqual(["onStart", "onFinish"]);
154
+ });
155
+
156
+ it("fires onError hook on failure", async () => {
157
+ const onError = vi.fn();
158
+ const ctx = createMockCtx();
159
+ const $ = createStepBuilder({ ctx });
160
+
161
+ await $.race({
162
+ id: "race-onerror",
163
+ entries: [() => Promise.reject(new Error("race failure"))],
164
+ onError,
165
+ });
166
+
167
+ expect(onError).toHaveBeenCalledTimes(1);
168
+ expect(onError).toHaveBeenCalledWith(
169
+ expect.objectContaining({
170
+ id: "race-onerror",
171
+ error: expect.any(Error),
172
+ }),
173
+ );
174
+ });
175
+
176
+ it("onFinish receives the winner result", async () => {
177
+ const onFinish = vi.fn();
178
+ const ctx = createMockCtx();
179
+ const $ = createStepBuilder({ ctx });
180
+
181
+ await $.race({
182
+ id: "race-finish",
183
+ entries: [() => Promise.resolve("winner")],
184
+ onFinish,
185
+ });
186
+
187
+ expect(onFinish).toHaveBeenCalledWith(
188
+ expect.objectContaining({
189
+ id: "race-finish",
190
+ result: "winner",
191
+ duration: expect.any(Number),
192
+ }),
193
+ );
194
+ });
195
+
196
+ it("records trace entry", async () => {
197
+ const ctx = createMockCtx();
198
+ const $ = createStepBuilder({ ctx });
199
+
200
+ await $.race({
201
+ id: "race-trace",
202
+ entries: [() => Promise.resolve("traced")],
203
+ });
204
+
205
+ const traceEntry = ctx.trace[0];
206
+ if (traceEntry === undefined) {
207
+ throw new Error("Expected trace entry");
208
+ }
209
+ expect(traceEntry.id).toBe("race-trace");
210
+ expect(traceEntry.type).toBe("race");
211
+ expect(traceEntry.output).toBe("traced");
212
+ });
213
+
214
+ it("passes child $ to entry factories", async () => {
215
+ const ctx = createMockCtx();
216
+ const $$ = createStepBuilder({ ctx });
217
+
218
+ await $$.race({
219
+ id: "race-child-$",
220
+ entries: [
221
+ async (_signal, $) => {
222
+ const inner = await $.step({
223
+ id: "inner-from-race",
224
+ execute: async () => "nested",
225
+ });
226
+ if (!inner.ok) {
227
+ throw new Error("inner failed");
228
+ }
229
+ return inner.value;
230
+ },
231
+ ],
232
+ });
233
+
234
+ const traceEntry = ctx.trace[0];
235
+ if (traceEntry === undefined) {
236
+ throw new Error("Expected trace entry");
237
+ }
238
+ expect(traceEntry.children).toHaveLength(1);
239
+ if (traceEntry.children === undefined) {
240
+ throw new Error("Expected children");
241
+ }
242
+ const child = traceEntry.children[0];
243
+ if (child === undefined) {
244
+ throw new Error("Expected child entry");
245
+ }
246
+ expect(child.id).toBe("inner-from-race");
247
+ });
248
+
249
+ it("aborts signal on error too (finally block)", async () => {
250
+ const ctx = createMockCtx();
251
+ const $ = createStepBuilder({ ctx });
252
+ const signals: { entry: AbortSignal | undefined } = { entry: undefined };
253
+
254
+ await $.race({
255
+ id: "race-abort-on-err",
256
+ entries: [
257
+ () => Promise.reject(new Error("fail")),
258
+ (signal) => {
259
+ signals.entry = signal;
260
+ return new Promise((r) => setTimeout(() => r("late"), 500));
261
+ },
262
+ ],
263
+ });
264
+
265
+ // The finally block in race() always aborts
266
+ if (signals.entry !== undefined) {
267
+ expect(signals.entry.aborted).toBe(true);
268
+ }
269
+ });
270
+
271
+ it("winner resolves before losers complete", async () => {
272
+ const ctx = createMockCtx();
273
+ const $ = createStepBuilder({ ctx });
274
+
275
+ const result = await $.race({
276
+ id: "race-timing",
277
+ entries: [
278
+ () => new Promise((r) => setTimeout(() => r("slow-1"), 100)),
279
+ () => new Promise((r) => setTimeout(() => r("slow-2"), 200)),
280
+ () => Promise.resolve("instant"),
281
+ ],
282
+ });
283
+
284
+ expect(result.ok).toBe(true);
285
+ if (!result.ok) {
286
+ return;
287
+ }
288
+ expect(result.value).toBe("instant");
289
+ });
290
+ });
@@ -0,0 +1,59 @@
1
+ import type { EntryFactory } from "@/core/agents/flow/steps/all.js";
2
+
3
+ /**
4
+ * Configuration for `$.race()` — first-to-finish wins.
5
+ *
6
+ * Like `Promise.race` — takes an array of factory functions. Returns
7
+ * the first resolved value. Losers are cancelled via abort signal.
8
+ */
9
+ export interface RaceConfig {
10
+ /**
11
+ * Unique step identifier.
12
+ *
13
+ * Appears in the execution trace.
14
+ */
15
+ id: string;
16
+
17
+ /**
18
+ * Array of factory functions to race.
19
+ *
20
+ * Each factory receives an `AbortSignal`. The first to resolve
21
+ * wins; losers are cancelled by aborting the signal.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * entries: [
26
+ * (signal) => $.step({ id: 'fast', execute: () => fetchFast(signal) }),
27
+ * (signal) => $.step({ id: 'slow', execute: () => fetchSlow(signal) }),
28
+ * ]
29
+ * ```
30
+ */
31
+ entries: EntryFactory[];
32
+
33
+ /**
34
+ * Hook: fires when the race starts.
35
+ *
36
+ * @param event - Event containing the step id.
37
+ * @param event.id - The step's unique identifier.
38
+ */
39
+ onStart?: (event: { id: string }) => void | Promise<void>;
40
+
41
+ /**
42
+ * Hook: fires when the first entry completes.
43
+ *
44
+ * @param event - Event containing the step id, winner result, and duration.
45
+ * @param event.id - The step's unique identifier.
46
+ * @param event.result - The first resolved value.
47
+ * @param event.duration - Wall-clock time in milliseconds.
48
+ */
49
+ onFinish?: (event: { id: string; result: unknown; duration: number }) => void | Promise<void>;
50
+
51
+ /**
52
+ * Hook: fires if the race encounters an error.
53
+ *
54
+ * @param event - Event containing the step id and error.
55
+ * @param event.id - The step's unique identifier.
56
+ * @param event.error - The error that occurred.
57
+ */
58
+ onError?: (event: { id: string; error: Error }) => void | Promise<void>;
59
+ }