@studio-foundation/runner 0.14.0 → 0.15.1

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 (60) hide show
  1. package/dist/generated/bundled-assets.d.ts +1 -1
  2. package/dist/generated/bundled-assets.js +1 -1
  3. package/dist/index.d.ts +4 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +10 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/prompt-cache.d.ts +33 -0
  8. package/dist/prompt-cache.d.ts.map +1 -0
  9. package/dist/prompt-cache.js +46 -0
  10. package/dist/prompt-cache.js.map +1 -0
  11. package/dist/prompt-cache.test.d.ts +2 -0
  12. package/dist/prompt-cache.test.d.ts.map +1 -0
  13. package/dist/prompt-cache.test.js +53 -0
  14. package/dist/prompt-cache.test.js.map +1 -0
  15. package/dist/providers/anthropic-batch.test.d.ts +2 -0
  16. package/dist/providers/anthropic-batch.test.d.ts.map +1 -0
  17. package/dist/providers/anthropic-batch.test.js +178 -0
  18. package/dist/providers/anthropic-batch.test.js.map +1 -0
  19. package/dist/providers/anthropic.d.ts +17 -1
  20. package/dist/providers/anthropic.d.ts.map +1 -1
  21. package/dist/providers/anthropic.js +126 -12
  22. package/dist/providers/anthropic.js.map +1 -1
  23. package/dist/providers/anthropic.test.js +69 -0
  24. package/dist/providers/anthropic.test.js.map +1 -1
  25. package/dist/providers/batch-window.d.ts +89 -0
  26. package/dist/providers/batch-window.d.ts.map +1 -0
  27. package/dist/providers/batch-window.js +309 -0
  28. package/dist/providers/batch-window.js.map +1 -0
  29. package/dist/providers/batch-window.test.d.ts +2 -0
  30. package/dist/providers/batch-window.test.d.ts.map +1 -0
  31. package/dist/providers/batch-window.test.js +317 -0
  32. package/dist/providers/batch-window.test.js.map +1 -0
  33. package/dist/providers/batch.d.ts +55 -0
  34. package/dist/providers/batch.d.ts.map +1 -0
  35. package/dist/providers/batch.js +35 -0
  36. package/dist/providers/batch.js.map +1 -0
  37. package/dist/providers/claude-code.d.ts.map +1 -1
  38. package/dist/providers/claude-code.js +72 -1
  39. package/dist/providers/claude-code.js.map +1 -1
  40. package/dist/providers/claude-code.test.js +95 -0
  41. package/dist/providers/claude-code.test.js.map +1 -1
  42. package/dist/providers/ollama.d.ts.map +1 -1
  43. package/dist/providers/ollama.js +4 -2
  44. package/dist/providers/ollama.js.map +1 -1
  45. package/dist/providers/openai-responses.d.ts.map +1 -1
  46. package/dist/providers/openai-responses.js +24 -18
  47. package/dist/providers/openai-responses.js.map +1 -1
  48. package/dist/providers/openai.d.ts.map +1 -1
  49. package/dist/providers/openai.js +26 -10
  50. package/dist/providers/openai.js.map +1 -1
  51. package/dist/providers/provider.d.ts +3 -6
  52. package/dist/providers/provider.d.ts.map +1 -1
  53. package/dist/providers/provider.js.map +1 -1
  54. package/dist/runner.d.ts +7 -6
  55. package/dist/runner.d.ts.map +1 -1
  56. package/dist/runner.js +17 -8
  57. package/dist/runner.js.map +1 -1
  58. package/dist/runner.test.js +137 -0
  59. package/dist/runner.test.js.map +1 -1
  60. package/package.json +3 -3
@@ -0,0 +1,309 @@
1
+ "use strict";
2
+ // A batch window — the barrier that turns N concurrent single calls into one
3
+ // batched submission.
4
+ //
5
+ // The problem it solves: a fan-out (`map`) stage spawns N independent child
6
+ // runs. Each one calls the LLM on its own, so N synchronous HTTP requests leave
7
+ // the process at full price. The Batch API bills the same tokens at half that —
8
+ // but only for requests that arrive in the same job.
9
+ //
10
+ // The window is that "same job". Every in-flight item joins it and gets a
11
+ // ticket; a ticket's submit() parks the request instead of sending it. The
12
+ // window flushes — one submitBatch per provider for everything parked — as soon
13
+ // as nothing live can still add to it: every participant is either parked or
14
+ // already inside a dispatch, or the batch has hit its size cap. A participant
15
+ // that finishes leaves, which lowers the bar for everyone still waiting.
16
+ //
17
+ // This is what lets the RALPH loop stay exactly as it was. Validation still
18
+ // happens per item, inside its own child run, after the batch comes back. An
19
+ // item that fails validation retries by calling again — and lands in the *next*
20
+ // flush, alongside whichever other items are also retrying. The batches shrink
21
+ // round after round without the retry loop knowing batching exists.
22
+ //
23
+ // Safety net: `flush_after_ms` of quiescence dispatches whatever is parked even
24
+ // while a participant is still busy elsewhere (a script stage, a hook, a slow
25
+ // tool). Without it, one slow item would hold every other one hostage.
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.BatchingProviderRegistry = exports.BatchWindow = exports.DEFAULT_FLUSH_AFTER_MS = exports.DEFAULT_MAX_BATCH_SIZE = void 0;
28
+ const provider_js_1 = require("./provider.js");
29
+ const batch_js_1 = require("./batch.js");
30
+ const registry_js_1 = require("./registry.js");
31
+ /** Requests per batch before the window dispatches without waiting for the barrier. */
32
+ exports.DEFAULT_MAX_BATCH_SIZE = 500;
33
+ /** Quiescence budget: dispatch what is parked after this long with no new arrival. */
34
+ exports.DEFAULT_FLUSH_AFTER_MS = 30_000;
35
+ class BatchWindow {
36
+ options;
37
+ participants = 0;
38
+ parked = [];
39
+ dispatching = 0;
40
+ seq = 0;
41
+ round = 0;
42
+ timer = null;
43
+ closed = false;
44
+ fallbacksReported = new Set();
45
+ constructor(options = {}) {
46
+ this.options = options;
47
+ if (options.signal) {
48
+ options.signal.addEventListener('abort', () => this.close(new DOMException('Aborted', 'AbortError')), {
49
+ once: true,
50
+ });
51
+ }
52
+ }
53
+ /** Register a participant. Every join() must be matched by a leave(), or the barrier never completes. */
54
+ join() {
55
+ this.participants++;
56
+ let left = false;
57
+ return {
58
+ submit: (provider, request, signal) => this.submit(provider, request, signal),
59
+ leave: () => {
60
+ if (left)
61
+ return;
62
+ left = true;
63
+ this.participants--;
64
+ // One fewer participant can still add to the batch — the barrier may now be met.
65
+ this.maybeFlush();
66
+ },
67
+ };
68
+ }
69
+ /** Reject everything still parked and stop accepting new requests. */
70
+ close(reason) {
71
+ if (this.closed)
72
+ return;
73
+ this.closed = true;
74
+ this.clearTimer();
75
+ const error = reason ?? new Error('Batch window closed before this request was dispatched.');
76
+ for (const entry of [...this.parked])
77
+ this.settle(entry, undefined, error);
78
+ }
79
+ /** Note that `providerName` cannot batch. Reports through onFallback once per name. */
80
+ noteFallback(providerName) {
81
+ if (this.fallbacksReported.has(providerName))
82
+ return;
83
+ this.fallbacksReported.add(providerName);
84
+ this.options.onFallback?.(providerName);
85
+ }
86
+ submit(provider, request, signal) {
87
+ if (this.closed) {
88
+ return Promise.reject(new Error('Batch window is closed; no further requests can be batched.'));
89
+ }
90
+ if (signal?.aborted || this.options.signal?.aborted) {
91
+ return Promise.reject(new DOMException('Aborted', 'AbortError'));
92
+ }
93
+ return new Promise((resolve, reject) => {
94
+ const entry = {
95
+ custom_id: `req_${this.seq++}`,
96
+ provider,
97
+ request,
98
+ resolve,
99
+ reject,
100
+ settled: false,
101
+ detach: () => { },
102
+ };
103
+ if (signal) {
104
+ const onAbort = () => this.settle(entry, undefined, new DOMException('Aborted', 'AbortError'));
105
+ signal.addEventListener('abort', onAbort, { once: true });
106
+ entry.detach = () => signal.removeEventListener('abort', onAbort);
107
+ }
108
+ this.parked.push(entry);
109
+ this.maybeFlush();
110
+ });
111
+ }
112
+ settle(entry, response, error) {
113
+ if (entry.settled)
114
+ return;
115
+ entry.settled = true;
116
+ entry.detach();
117
+ const index = this.parked.indexOf(entry);
118
+ if (index >= 0)
119
+ this.parked.splice(index, 1);
120
+ if (error !== undefined)
121
+ entry.reject(error);
122
+ else
123
+ entry.resolve(response);
124
+ }
125
+ capFor(provider) {
126
+ const configured = this.options.max_size ?? exports.DEFAULT_MAX_BATCH_SIZE;
127
+ return Math.max(1, Math.min(configured, provider.maxBatchSize));
128
+ }
129
+ /**
130
+ * Dispatch whatever is ready. `force` (the quiescence timer) sends everything
131
+ * parked regardless of who is still busy.
132
+ */
133
+ maybeFlush(force = false) {
134
+ if (this.closed)
135
+ return;
136
+ for (;;) {
137
+ if (this.parked.length === 0) {
138
+ this.clearTimer();
139
+ return;
140
+ }
141
+ // The barrier: nobody live can still add to this batch.
142
+ const barrierMet = force || this.parked.length + this.dispatching >= this.participants;
143
+ const groups = new Map();
144
+ for (const entry of this.parked) {
145
+ const group = groups.get(entry.provider.name);
146
+ if (group)
147
+ group.push(entry);
148
+ else
149
+ groups.set(entry.provider.name, [entry]);
150
+ }
151
+ let dispatched = false;
152
+ for (const group of groups.values()) {
153
+ const cap = this.capFor(group[0].provider);
154
+ if (group.length >= cap) {
155
+ this.dispatch(group.slice(0, cap));
156
+ dispatched = true;
157
+ }
158
+ else if (barrierMet) {
159
+ this.dispatch(group);
160
+ dispatched = true;
161
+ }
162
+ }
163
+ if (!dispatched) {
164
+ this.armTimer();
165
+ return;
166
+ }
167
+ // A group may still be over cap after its first slice — re-evaluate.
168
+ }
169
+ }
170
+ dispatch(entries) {
171
+ for (const entry of entries) {
172
+ const index = this.parked.indexOf(entry);
173
+ if (index >= 0)
174
+ this.parked.splice(index, 1);
175
+ }
176
+ this.clearTimer();
177
+ const provider = entries[0].provider;
178
+ const round = ++this.round;
179
+ const startedAt = Date.now();
180
+ this.dispatching += entries.length;
181
+ this.options.onDispatch?.({ provider: provider.name, size: entries.length, round });
182
+ void provider
183
+ .submitBatch(entries.map(entry => ({ custom_id: entry.custom_id, request: entry.request })), {
184
+ signal: this.options.signal,
185
+ ...(this.options.poll_interval_ms !== undefined ? { poll_interval_ms: this.options.poll_interval_ms } : {}),
186
+ ...(this.options.max_wait_ms !== undefined ? { max_wait_ms: this.options.max_wait_ms } : {}),
187
+ ...(this.options.onProgress ? { onProgress: this.options.onProgress } : {}),
188
+ })
189
+ .then(results => {
190
+ const byId = new Map(results.map(result => [result.custom_id, result]));
191
+ let succeeded = 0;
192
+ let failed = 0;
193
+ for (const entry of entries) {
194
+ const result = byId.get(entry.custom_id);
195
+ if (result?.response) {
196
+ succeeded++;
197
+ this.settle(entry, result.response);
198
+ }
199
+ else {
200
+ failed++;
201
+ // A per-request failure is an ordinary executor error: the child
202
+ // run's RALPH loop sees it, enriches, and retries into a later batch.
203
+ this.settle(entry, undefined, new Error(result?.error ?? 'Batch returned no result for this request.'));
204
+ }
205
+ }
206
+ this.options.onSettled?.({
207
+ provider: provider.name,
208
+ size: entries.length,
209
+ round,
210
+ succeeded,
211
+ failed,
212
+ duration_ms: Date.now() - startedAt,
213
+ });
214
+ })
215
+ .catch((err) => {
216
+ for (const entry of entries)
217
+ this.settle(entry, undefined, err);
218
+ this.options.onSettled?.({
219
+ provider: provider.name,
220
+ size: entries.length,
221
+ round,
222
+ succeeded: 0,
223
+ failed: entries.length,
224
+ duration_ms: Date.now() - startedAt,
225
+ });
226
+ })
227
+ .finally(() => {
228
+ this.dispatching -= entries.length;
229
+ this.maybeFlush();
230
+ });
231
+ }
232
+ armTimer() {
233
+ const delay = this.options.flush_after_ms ?? exports.DEFAULT_FLUSH_AFTER_MS;
234
+ if (delay <= 0 || this.timer !== null || this.parked.length === 0)
235
+ return;
236
+ this.timer = setTimeout(() => {
237
+ this.timer = null;
238
+ this.maybeFlush(true);
239
+ }, delay);
240
+ // Never keep the process alive just to wait on a batch that nobody is filling.
241
+ this.timer.unref?.();
242
+ }
243
+ clearTimer() {
244
+ if (this.timer === null)
245
+ return;
246
+ clearTimeout(this.timer);
247
+ this.timer = null;
248
+ }
249
+ }
250
+ exports.BatchWindow = BatchWindow;
251
+ /**
252
+ * A Provider face over one ticket: `call()` parks the request in the window
253
+ * instead of sending it. Streaming is dropped rather than faked — a batched
254
+ * response arrives whole, so there are no tokens to emit.
255
+ */
256
+ class BatchedProvider {
257
+ base;
258
+ ticket;
259
+ name;
260
+ constructor(base, ticket) {
261
+ this.base = base;
262
+ this.ticket = ticket;
263
+ this.name = base.name;
264
+ }
265
+ call(request, _onToken, signal) {
266
+ return this.ticket.submit(this.base, request, signal);
267
+ }
268
+ }
269
+ /**
270
+ * A registry that hands out batched providers for the duration of one map item.
271
+ *
272
+ * Everything else passes through untouched: a provider that cannot batch, or
273
+ * one that owns its own agent loop (where there is no single call to intercept),
274
+ * is returned as-is — so `--provider mock` and `--provider claude-code` keep
275
+ * working under a `batch:` map, just without the discount.
276
+ */
277
+ class BatchingProviderRegistry extends registry_js_1.ProviderRegistry {
278
+ base;
279
+ ticket;
280
+ window;
281
+ constructor(base, ticket, window) {
282
+ super();
283
+ this.base = base;
284
+ this.ticket = ticket;
285
+ this.window = window;
286
+ }
287
+ register(provider) {
288
+ this.base.register(provider);
289
+ }
290
+ registerLazy(name, factory) {
291
+ this.base.registerLazy(name, factory);
292
+ }
293
+ get(name) {
294
+ const provider = this.base.get(name);
295
+ if (!(0, batch_js_1.isBatchProvider)(provider) || (0, provider_js_1.isAgentLoopProvider)(provider)) {
296
+ this.window.noteFallback(provider.name);
297
+ return provider;
298
+ }
299
+ return new BatchedProvider(provider, this.ticket);
300
+ }
301
+ has(name) {
302
+ return this.base.has(name);
303
+ }
304
+ list() {
305
+ return this.base.list();
306
+ }
307
+ }
308
+ exports.BatchingProviderRegistry = BatchingProviderRegistry;
309
+ //# sourceMappingURL=batch-window.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch-window.js","sourceRoot":"","sources":["../../src/providers/batch-window.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=batch-window.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch-window.test.d.ts","sourceRoot":"","sources":["../../src/providers/batch-window.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,317 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const batch_js_1 = require("./batch.js");
5
+ const batch_window_js_1 = require("./batch-window.js");
6
+ const registry_js_1 = require("./registry.js");
7
+ /**
8
+ * A batch provider whose dispatches are settled by the test, so the barrier can
9
+ * be observed without any timing assumptions.
10
+ */
11
+ class FakeBatchProvider {
12
+ maxBatchSize;
13
+ autoRespond;
14
+ name = 'fake-batch';
15
+ calls = [];
16
+ pending = [];
17
+ constructor(maxBatchSize = 100, autoRespond = true) {
18
+ this.maxBatchSize = maxBatchSize;
19
+ this.autoRespond = autoRespond;
20
+ }
21
+ async submitBatch(items, _options) {
22
+ this.calls.push(items);
23
+ if (this.autoRespond) {
24
+ return items.map(item => ({ custom_id: item.custom_id, response: echo(item.request) }));
25
+ }
26
+ return new Promise(resolve => {
27
+ this.pending.push(resolve);
28
+ });
29
+ }
30
+ /** Settle the Nth outstanding dispatch with successes. */
31
+ settle(index = 0, results) {
32
+ const resolve = this.pending[index];
33
+ const items = this.calls[index];
34
+ resolve(results ?? items.map(item => ({ custom_id: item.custom_id, response: echo(item.request) })));
35
+ }
36
+ async call() {
37
+ throw new Error('FakeBatchProvider: batched calls only');
38
+ }
39
+ }
40
+ function echo(request) {
41
+ return {
42
+ content: JSON.stringify({ echoed: request.messages.at(-1)?.content ?? '' }),
43
+ tool_calls: [],
44
+ finish_reason: 'stop',
45
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
46
+ };
47
+ }
48
+ function req(text) {
49
+ return { model: 'test-model', messages: [{ role: 'user', content: text }] };
50
+ }
51
+ /** Let queued microtasks (and any pending timers set to 0) drain. */
52
+ const tick = () => new Promise(resolve => setImmediate(resolve));
53
+ (0, vitest_1.describe)('BatchWindow — the barrier', () => {
54
+ (0, vitest_1.it)('holds requests until every participant has parked, then sends one batch', async () => {
55
+ const provider = new FakeBatchProvider(100, false);
56
+ const window = new batch_window_js_1.BatchWindow({ flush_after_ms: 0 });
57
+ const tickets = [window.join(), window.join(), window.join()];
58
+ const first = tickets[0].submit(provider, req('a'));
59
+ await tick();
60
+ (0, vitest_1.expect)(provider.calls).toHaveLength(0); // two participants have not parked yet
61
+ const second = tickets[1].submit(provider, req('b'));
62
+ await tick();
63
+ (0, vitest_1.expect)(provider.calls).toHaveLength(0);
64
+ const third = tickets[2].submit(provider, req('c'));
65
+ await tick();
66
+ (0, vitest_1.expect)(provider.calls).toHaveLength(1);
67
+ (0, vitest_1.expect)(provider.calls[0]).toHaveLength(3);
68
+ provider.settle(0);
69
+ const responses = await Promise.all([first, second, third]);
70
+ (0, vitest_1.expect)(responses.map(r => JSON.parse(r.content).echoed)).toEqual(['a', 'b', 'c']);
71
+ });
72
+ (0, vitest_1.it)('dispatches without the stragglers once they leave', async () => {
73
+ const provider = new FakeBatchProvider(100, false);
74
+ const window = new batch_window_js_1.BatchWindow({ flush_after_ms: 0 });
75
+ const [parked, leaver] = [window.join(), window.join()];
76
+ const pending = parked.submit(provider, req('a'));
77
+ await tick();
78
+ (0, vitest_1.expect)(provider.calls).toHaveLength(0);
79
+ // The second item finished (cache hit, failure, no LLM call) — it will
80
+ // never park, so the batch must not keep waiting for it.
81
+ leaver.leave();
82
+ await tick();
83
+ (0, vitest_1.expect)(provider.calls).toHaveLength(1);
84
+ provider.settle(0);
85
+ await (0, vitest_1.expect)(pending).resolves.toMatchObject({ finish_reason: 'stop' });
86
+ });
87
+ (0, vitest_1.it)('splits at max_size instead of waiting for the barrier', async () => {
88
+ const provider = new FakeBatchProvider(100, false);
89
+ const window = new batch_window_js_1.BatchWindow({ max_size: 2, flush_after_ms: 0 });
90
+ const tickets = [window.join(), window.join(), window.join(), window.join()];
91
+ const pending = [
92
+ tickets[0].submit(provider, req('a')),
93
+ tickets[1].submit(provider, req('b')),
94
+ ];
95
+ await tick();
96
+ (0, vitest_1.expect)(provider.calls).toHaveLength(1);
97
+ (0, vitest_1.expect)(provider.calls[0]).toHaveLength(2);
98
+ pending.push(tickets[2].submit(provider, req('c')), tickets[3].submit(provider, req('d')));
99
+ await tick();
100
+ (0, vitest_1.expect)(provider.calls).toHaveLength(2);
101
+ provider.settle(0);
102
+ provider.settle(1);
103
+ await (0, vitest_1.expect)(Promise.all(pending)).resolves.toHaveLength(4);
104
+ });
105
+ (0, vitest_1.it)('honours the provider ceiling over a larger configured max_size', async () => {
106
+ const provider = new FakeBatchProvider(2, false);
107
+ const window = new batch_window_js_1.BatchWindow({ max_size: 1000, flush_after_ms: 0 });
108
+ const tickets = [window.join(), window.join(), window.join()];
109
+ const pending = tickets.map((ticket, i) => ticket.submit(provider, req(`item-${i}`)));
110
+ await tick();
111
+ // 3 parked, provider caps at 2 → one full batch out, the remainder follows
112
+ // on the barrier (all three are parked, none are busy).
113
+ (0, vitest_1.expect)(provider.calls[0]).toHaveLength(2);
114
+ (0, vitest_1.expect)(provider.calls[1]).toHaveLength(1);
115
+ provider.settle(0);
116
+ provider.settle(1);
117
+ await (0, vitest_1.expect)(Promise.all(pending)).resolves.toHaveLength(3);
118
+ });
119
+ (0, vitest_1.it)('flushes on quiescence when a participant is busy elsewhere', async () => {
120
+ vitest_1.vi.useFakeTimers();
121
+ try {
122
+ const provider = new FakeBatchProvider(100, false);
123
+ const window = new batch_window_js_1.BatchWindow({ flush_after_ms: 1000 });
124
+ const [parked] = [window.join(), window.join()]; // second never parks
125
+ const pending = parked.submit(provider, req('a'));
126
+ (0, vitest_1.expect)(provider.calls).toHaveLength(0);
127
+ vitest_1.vi.advanceTimersByTime(1000);
128
+ (0, vitest_1.expect)(provider.calls).toHaveLength(1);
129
+ provider.settle(0);
130
+ vitest_1.vi.useRealTimers();
131
+ await (0, vitest_1.expect)(pending).resolves.toMatchObject({ finish_reason: 'stop' });
132
+ }
133
+ finally {
134
+ vitest_1.vi.useRealTimers();
135
+ }
136
+ });
137
+ (0, vitest_1.it)('rejects only the request that errored, leaving its peers intact', async () => {
138
+ const provider = new FakeBatchProvider(100, false);
139
+ const window = new batch_window_js_1.BatchWindow({ flush_after_ms: 0 });
140
+ const tickets = [window.join(), window.join()];
141
+ const good = tickets[0].submit(provider, req('good'));
142
+ const bad = tickets[1].submit(provider, req('bad'));
143
+ await tick();
144
+ const [goodId, badId] = provider.calls[0].map(item => item.custom_id);
145
+ provider.settle(0, [
146
+ { custom_id: goodId, response: echo(req('good')) },
147
+ { custom_id: badId, error: 'overloaded_error' },
148
+ ]);
149
+ await (0, vitest_1.expect)(good).resolves.toMatchObject({ finish_reason: 'stop' });
150
+ // A per-request failure surfaces as an ordinary executor error, which is
151
+ // what lets the child run's RALPH loop retry it into the next batch.
152
+ await (0, vitest_1.expect)(bad).rejects.toThrow('overloaded_error');
153
+ });
154
+ (0, vitest_1.it)('fails every request of a batch the provider refused', async () => {
155
+ const provider = {
156
+ name: 'refuser',
157
+ maxBatchSize: 10,
158
+ call: async () => { throw new Error('unused'); },
159
+ submitBatch: async () => { throw new Error('batch submission refused'); },
160
+ };
161
+ const window = new batch_window_js_1.BatchWindow({ flush_after_ms: 0 });
162
+ const tickets = [window.join(), window.join()];
163
+ const pending = tickets.map(t => t.submit(provider, req('x')));
164
+ await (0, vitest_1.expect)(Promise.allSettled(pending)).resolves.toEqual([
165
+ vitest_1.expect.objectContaining({ status: 'rejected' }),
166
+ vitest_1.expect.objectContaining({ status: 'rejected' }),
167
+ ]);
168
+ });
169
+ (0, vitest_1.it)('reports each dispatch and its outcome', async () => {
170
+ const provider = new FakeBatchProvider();
171
+ const onDispatch = vitest_1.vi.fn();
172
+ const onSettled = vitest_1.vi.fn();
173
+ const window = new batch_window_js_1.BatchWindow({ flush_after_ms: 0, onDispatch, onSettled });
174
+ const ticket = window.join();
175
+ await ticket.submit(provider, req('a'));
176
+ (0, vitest_1.expect)(onDispatch).toHaveBeenCalledWith({ provider: 'fake-batch', size: 1, round: 1 });
177
+ (0, vitest_1.expect)(onSettled).toHaveBeenCalledWith(vitest_1.expect.objectContaining({ provider: 'fake-batch', size: 1, round: 1, succeeded: 1, failed: 0 }));
178
+ });
179
+ (0, vitest_1.it)('groups a mixed window by provider — one batch each', async () => {
180
+ const alpha = new FakeBatchProvider(100, false);
181
+ const beta = new FakeBatchProvider(100, false);
182
+ Object.defineProperty(beta, 'name', { value: 'other-batch' });
183
+ const window = new batch_window_js_1.BatchWindow({ flush_after_ms: 0 });
184
+ const tickets = [window.join(), window.join()];
185
+ const pending = [tickets[0].submit(alpha, req('a')), tickets[1].submit(beta, req('b'))];
186
+ await tick();
187
+ (0, vitest_1.expect)(alpha.calls).toHaveLength(1);
188
+ (0, vitest_1.expect)(beta.calls).toHaveLength(1);
189
+ alpha.settle(0);
190
+ beta.settle(0);
191
+ await (0, vitest_1.expect)(Promise.all(pending)).resolves.toHaveLength(2);
192
+ });
193
+ (0, vitest_1.it)('rejects what is still parked when the window closes', async () => {
194
+ const provider = new FakeBatchProvider(100, false);
195
+ const window = new batch_window_js_1.BatchWindow({ flush_after_ms: 0 });
196
+ window.join(); // a participant that never parks holds the barrier
197
+ const ticket = window.join();
198
+ const pending = ticket.submit(provider, req('a'));
199
+ await tick();
200
+ (0, vitest_1.expect)(provider.calls).toHaveLength(0);
201
+ window.close();
202
+ await (0, vitest_1.expect)(pending).rejects.toThrow(/closed/);
203
+ });
204
+ (0, vitest_1.it)('aborts parked requests when the run is cancelled', async () => {
205
+ const provider = new FakeBatchProvider(100, false);
206
+ const controller = new AbortController();
207
+ const window = new batch_window_js_1.BatchWindow({ flush_after_ms: 0, signal: controller.signal });
208
+ window.join();
209
+ const ticket = window.join();
210
+ const pending = ticket.submit(provider, req('a'));
211
+ await tick();
212
+ controller.abort();
213
+ await (0, vitest_1.expect)(pending).rejects.toThrow();
214
+ });
215
+ });
216
+ (0, vitest_1.describe)('BatchingProviderRegistry', () => {
217
+ (0, vitest_1.it)('batches a provider that can, and passes through one that cannot', async () => {
218
+ const batchable = new FakeBatchProvider();
219
+ const plain = {
220
+ name: 'plain',
221
+ call: async () => echo(req('direct')),
222
+ };
223
+ const base = new registry_js_1.ProviderRegistry();
224
+ base.register(batchable);
225
+ base.register(plain);
226
+ const onFallback = vitest_1.vi.fn();
227
+ const window = new batch_window_js_1.BatchWindow({ flush_after_ms: 0, onFallback });
228
+ const registry = new batch_window_js_1.BatchingProviderRegistry(base, window.join(), window);
229
+ // Same name, so agent YAML and config never have to know about batching.
230
+ const wrapped = registry.get('fake-batch');
231
+ (0, vitest_1.expect)(wrapped.name).toBe('fake-batch');
232
+ (0, vitest_1.expect)(wrapped).not.toBe(batchable);
233
+ await wrapped.call(req('a'));
234
+ (0, vitest_1.expect)(batchable.calls).toHaveLength(1);
235
+ (0, vitest_1.expect)(registry.get('plain')).toBe(plain);
236
+ (0, vitest_1.expect)(onFallback).toHaveBeenCalledWith('plain');
237
+ });
238
+ (0, vitest_1.it)('leaves a provider that owns its own agent loop alone', () => {
239
+ // There is no single call to intercept in a provider-owned loop, so it must
240
+ // run unbatched rather than silently losing its tool turns.
241
+ const loopProvider = {
242
+ name: 'looping',
243
+ maxBatchSize: 10,
244
+ call: async () => echo(req('x')),
245
+ submitBatch: async (items) => items.map(item => ({ custom_id: item.custom_id, response: echo(item.request) })),
246
+ runAgentLoop: async (_r, _e) => ({ content: '{}', tool_calls: [], finish_reason: 'stop' }),
247
+ };
248
+ const base = new registry_js_1.ProviderRegistry();
249
+ base.register(loopProvider);
250
+ const window = new batch_window_js_1.BatchWindow({ flush_after_ms: 0 });
251
+ const registry = new batch_window_js_1.BatchingProviderRegistry(base, window.join(), window);
252
+ (0, vitest_1.expect)(registry.get('looping')).toBe(loopProvider);
253
+ });
254
+ (0, vitest_1.it)('delegates has()/list() to the registry it wraps', () => {
255
+ const base = new registry_js_1.ProviderRegistry();
256
+ base.register(new FakeBatchProvider());
257
+ const window = new batch_window_js_1.BatchWindow({ flush_after_ms: 0 });
258
+ const registry = new batch_window_js_1.BatchingProviderRegistry(base, window.join(), window);
259
+ (0, vitest_1.expect)(registry.has('fake-batch')).toBe(true);
260
+ (0, vitest_1.expect)(registry.has('nope')).toBe(false);
261
+ (0, vitest_1.expect)(registry.list()).toEqual(['fake-batch']);
262
+ });
263
+ });
264
+ (0, vitest_1.describe)('batch capability helpers', () => {
265
+ (0, vitest_1.it)('detects a batch provider by its submitBatch method', () => {
266
+ (0, vitest_1.expect)((0, batch_js_1.isBatchProvider)(new FakeBatchProvider())).toBe(true);
267
+ (0, vitest_1.expect)((0, batch_js_1.isBatchProvider)({ name: 'plain', call: async () => echo(req('x')) })).toBe(false);
268
+ });
269
+ (0, vitest_1.it)('rejects custom_ids the API would reject', () => {
270
+ (0, vitest_1.expect)(() => (0, batch_js_1.assertValidBatch)([{ custom_id: 'ok_1', request: req('a') }])).not.toThrow();
271
+ (0, vitest_1.expect)(() => (0, batch_js_1.assertValidBatch)([{ custom_id: 'has space', request: req('a') }])).toThrow(/custom_id/);
272
+ (0, vitest_1.expect)(() => (0, batch_js_1.assertValidBatch)([{ custom_id: '', request: req('a') }])).toThrow(/custom_id/);
273
+ (0, vitest_1.expect)(() => (0, batch_js_1.assertValidBatch)([
274
+ { custom_id: 'dup', request: req('a') },
275
+ { custom_id: 'dup', request: req('b') },
276
+ ])).toThrow(/Duplicate/);
277
+ });
278
+ });
279
+ (0, vitest_1.describe)('BatchWindow — token usage (STU-750)', () => {
280
+ (0, vitest_1.it)('hands each parked caller the usage its own batched response reported', async () => {
281
+ const provider = new FakeBatchProvider(100, false);
282
+ const window = new batch_window_js_1.BatchWindow({ flush_after_ms: 0 });
283
+ const tickets = [window.join(), window.join()];
284
+ const first = tickets[0].submit(provider, req('a'));
285
+ const second = tickets[1].submit(provider, req('b'));
286
+ await tick();
287
+ provider.settle(0, [
288
+ {
289
+ custom_id: provider.calls[0][0].custom_id,
290
+ response: {
291
+ ...echo(req('a')),
292
+ usage: {
293
+ prompt_tokens: 10, completion_tokens: 2, total_tokens: 12,
294
+ by_model: { 'test-model': { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 } },
295
+ },
296
+ },
297
+ },
298
+ {
299
+ custom_id: provider.calls[0][1].custom_id,
300
+ response: {
301
+ ...echo(req('b')),
302
+ usage: {
303
+ prompt_tokens: 300, completion_tokens: 40, total_tokens: 340,
304
+ by_model: { 'test-model': { prompt_tokens: 300, completion_tokens: 40, total_tokens: 340 } },
305
+ },
306
+ },
307
+ },
308
+ ]);
309
+ // Batching changes how the requests leave the process, not what they cost:
310
+ // each item must still get its own counts back, or a batched fan-out — the
311
+ // whole reason cost tracking exists — would report nothing.
312
+ (0, vitest_1.expect)((await first).usage?.total_tokens).toBe(12);
313
+ (0, vitest_1.expect)((await second).usage?.total_tokens).toBe(340);
314
+ (0, vitest_1.expect)((await second).usage?.by_model?.['test-model'].prompt_tokens).toBe(300);
315
+ });
316
+ });
317
+ //# sourceMappingURL=batch-window.test.js.map