jev-agent-tools 0.1.3

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 (95) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -0
  4. package/docs/design.md +187 -0
  5. package/docs/tools/jev_ask.md +92 -0
  6. package/docs/tools/jev_ask_files.md +80 -0
  7. package/docs/tools/jev_check_diff.md +77 -0
  8. package/docs/tools/jev_find_files.md +59 -0
  9. package/docs/tools/jev_locate_in_file.md +51 -0
  10. package/docs/tools/jev_select_tests.md +59 -0
  11. package/package.json +85 -0
  12. package/rules/jev-ask.md +4 -0
  13. package/src/adapters/analysis-context.ts +100 -0
  14. package/src/adapters/ask-files.ts +236 -0
  15. package/src/adapters/ask-proof.ts +202 -0
  16. package/src/adapters/ask-syntax.ts +463 -0
  17. package/src/adapters/command.ts +240 -0
  18. package/src/adapters/docs.ts +222 -0
  19. package/src/adapters/files.ts +411 -0
  20. package/src/adapters/find.ts +151 -0
  21. package/src/adapters/git-base.ts +32 -0
  22. package/src/adapters/git-inventory.ts +94 -0
  23. package/src/adapters/git.ts +525 -0
  24. package/src/adapters/locate-file.ts +197 -0
  25. package/src/adapters/output-lines.ts +50 -0
  26. package/src/adapters/risk-callers.ts +525 -0
  27. package/src/adapters/runner-version.ts +102 -0
  28. package/src/adapters/syntax.ts +229 -0
  29. package/src/adapters/test-inventory.ts +168 -0
  30. package/src/adapters/usage.ts +21 -0
  31. package/src/adapters/utf8.ts +57 -0
  32. package/src/constants.ts +109 -0
  33. package/src/core/ask-closure.ts +419 -0
  34. package/src/core/ask-proof.ts +32 -0
  35. package/src/core/ask-references.ts +249 -0
  36. package/src/core/asks.ts +616 -0
  37. package/src/core/batches.ts +83 -0
  38. package/src/core/command-output.ts +249 -0
  39. package/src/core/diff.ts +226 -0
  40. package/src/core/docs.ts +399 -0
  41. package/src/core/find.ts +157 -0
  42. package/src/core/git.ts +5 -0
  43. package/src/core/import-boundaries.ts +102 -0
  44. package/src/core/imports.ts +691 -0
  45. package/src/core/integrity.ts +64 -0
  46. package/src/core/lexical.ts +130 -0
  47. package/src/core/locate.ts +213 -0
  48. package/src/core/output.ts +264 -0
  49. package/src/core/pointer.ts +51 -0
  50. package/src/core/risk-callers.ts +1270 -0
  51. package/src/core/runner-version.ts +66 -0
  52. package/src/core/sections.ts +269 -0
  53. package/src/core/state.ts +53 -0
  54. package/src/core/syntax.ts +8 -0
  55. package/src/core/test-commands.ts +430 -0
  56. package/src/core/test-coverage.ts +103 -0
  57. package/src/core/test-discovery.ts +1695 -0
  58. package/src/core/test-evidence.ts +649 -0
  59. package/src/core/test-state.ts +99 -0
  60. package/src/core/truncate.ts +14 -0
  61. package/src/core/units.ts +531 -0
  62. package/src/describe.ts +26 -0
  63. package/src/guide.ts +42 -0
  64. package/src/host.ts +22 -0
  65. package/src/index.ts +40 -0
  66. package/src/jev/client.ts +505 -0
  67. package/src/jev/pool.ts +60 -0
  68. package/src/jev/types.ts +60 -0
  69. package/src/presets/docs.ts +85 -0
  70. package/src/presets/risk.ts +263 -0
  71. package/src/presets/spec.ts +111 -0
  72. package/src/presets/witnesses.ts +313 -0
  73. package/src/render.ts +45 -0
  74. package/src/result.ts +4 -0
  75. package/src/run-end.ts +157 -0
  76. package/src/runtime.ts +16 -0
  77. package/src/session.ts +106 -0
  78. package/src/texts/ask-files.ts +2 -0
  79. package/src/texts/ask.ts +4 -0
  80. package/src/texts/check-diff.ts +24 -0
  81. package/src/texts/configuration.ts +2 -0
  82. package/src/texts/find.ts +14 -0
  83. package/src/texts/guide.ts +16 -0
  84. package/src/texts/locate.ts +10 -0
  85. package/src/texts/run-end.ts +13 -0
  86. package/src/texts/select-tests.ts +3 -0
  87. package/src/tools/ask-files.ts +263 -0
  88. package/src/tools/ask-schema.ts +116 -0
  89. package/src/tools/ask.ts +925 -0
  90. package/src/tools/check-diff.ts +510 -0
  91. package/src/tools/docs-check.ts +399 -0
  92. package/src/tools/find.ts +529 -0
  93. package/src/tools/locate.ts +369 -0
  94. package/src/tools/select-tests.ts +746 -0
  95. package/src/tools/spec-check.ts +210 -0
package/src/guide.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { interpolate } from "./describe.ts";
3
+ import type { Host } from "./host.ts";
4
+ import { GUIDE_TEMPLATE } from "./texts/guide.ts";
5
+
6
+ export interface GuideContext {
7
+ addAdditionalContext?: (text: string) => void;
8
+ }
9
+ export class Guide {
10
+ private delivered = false;
11
+ private readonly host: Host;
12
+ readonly text: string;
13
+ constructor(host: Host) {
14
+ this.host = host;
15
+ this.text = interpolate(GUIDE_TEMPLATE, host.names);
16
+ }
17
+ attach(api: Pick<ExtensionAPI, "on">): void {
18
+ api.on("before_agent_start", (event) => {
19
+ if (!this.host.isOmp) {
20
+ if (
21
+ event.systemPromptOptions.selectedTools.some((name) =>
22
+ name.startsWith("jev_"),
23
+ )
24
+ )
25
+ event.systemPromptOptions.sections.jev_guide = this.text;
26
+ else delete event.systemPromptOptions.sections.jev_guide;
27
+ }
28
+ });
29
+ api.on("session_compact", () => {
30
+ this.reset();
31
+ });
32
+ }
33
+ deliver(ctx: GuideContext): void {
34
+ if (this.host.isOmp && !this.delivered && ctx.addAdditionalContext) {
35
+ ctx.addAdditionalContext(this.text);
36
+ this.delivered = true;
37
+ }
38
+ }
39
+ reset(): void {
40
+ this.delivered = false;
41
+ }
42
+ }
package/src/host.ts ADDED
@@ -0,0 +1,22 @@
1
+ export interface Host {
2
+ isOmp: boolean;
3
+ names: Names;
4
+ }
5
+ export function detectHost(api: { pi?: unknown }): Host {
6
+ const isOmp = api.pi !== undefined;
7
+ return {
8
+ isOmp,
9
+ names: {
10
+ grep: "grep",
11
+ byName: isOmp ? "glob" : "find",
12
+ semantic: isOmp
13
+ ? "find or jev_find_files, whichever is available"
14
+ : "jev_find_files",
15
+ },
16
+ };
17
+ }
18
+ export interface Names {
19
+ grep: string;
20
+ byName: string;
21
+ semantic: string;
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Guide } from "./guide.ts";
3
+ import { detectHost } from "./host.ts";
4
+ import { createJevClient, readConfig } from "./jev/client.ts";
5
+ import { RunEnd, type RunEndHost } from "./run-end.ts";
6
+ import { readSessionLimits, Session } from "./session.ts";
7
+ import { createAskTool } from "./tools/ask.ts";
8
+ import { createAskFilesTool } from "./tools/ask-files.ts";
9
+ import { createCheckDiffTool } from "./tools/check-diff.ts";
10
+ import { createFindFilesTool } from "./tools/find.ts";
11
+ import { createLocateTool } from "./tools/locate.ts";
12
+ import { createSelectTestsTool } from "./tools/select-tests.ts";
13
+ export default function extension(api: ExtensionAPI & { pi?: unknown }): void {
14
+ const host = detectHost(api);
15
+ const config = readConfig(process.env);
16
+ const client = config ? createJevClient(config) : undefined;
17
+ const runtime = {
18
+ session: new Session(readSessionLimits(process.env)),
19
+ guide: new Guide(host),
20
+ };
21
+ runtime.guide.attach(api);
22
+ api.on("session_start", async () => {
23
+ runtime.session.reset();
24
+ runtime.guide.reset();
25
+ client?.clearCache();
26
+ if (host.isOmp) {
27
+ const active = api.getActiveTools();
28
+ if (!active.includes("find"))
29
+ await api.setActiveTools([...active, "jev_find_files"]);
30
+ }
31
+ });
32
+ const dependencies = { client, host, runtime, exec: api.exec.bind(api) };
33
+ new RunEnd(dependencies).attach(api as unknown as RunEndHost);
34
+ api.registerTool(createAskTool(dependencies));
35
+ api.registerTool(createSelectTestsTool(dependencies));
36
+ api.registerTool(createCheckDiffTool(dependencies));
37
+ api.registerTool(createFindFilesTool(dependencies));
38
+ api.registerTool(createLocateTool(dependencies));
39
+ api.registerTool(createAskFilesTool(dependencies));
40
+ }
@@ -0,0 +1,505 @@
1
+ import { createHash } from "node:crypto";
2
+ import { setTimeout as timerSleep } from "node:timers/promises";
3
+ import {
4
+ HTTP_ERROR_MAX_CHARS,
5
+ REQUEST_ATTEMPTS,
6
+ RETRY_BASE_MS,
7
+ RETRY_MAX_MS,
8
+ TIMEOUT_MS,
9
+ } from "../constants.ts";
10
+ import { prepareBatches, splitGroups } from "../core/batches.ts";
11
+ import { truncate } from "../core/truncate.ts";
12
+ import { isRecord } from "../result.ts";
13
+ import { type Clock, createPool, type Pool } from "./pool.ts";
14
+ import type {
15
+ Answer,
16
+ JevClient,
17
+ Json,
18
+ Judgment,
19
+ JudgmentMetadata,
20
+ Question,
21
+ } from "./types.ts";
22
+ export interface JevConfig {
23
+ url: string;
24
+ apiKey: string;
25
+ model: string;
26
+ }
27
+ export interface ClientDependencies extends Clock {
28
+ fetch: typeof fetch;
29
+ timeoutMs: number;
30
+ pool?: Pool;
31
+ }
32
+ export function readConfig(env: NodeJS.ProcessEnv): JevConfig | undefined {
33
+ return env.JEV_TOOLS_URL && env.JEV_TOOLS_API_KEY
34
+ ? {
35
+ url: env.JEV_TOOLS_URL,
36
+ apiKey: env.JEV_TOOLS_API_KEY,
37
+ model: env.JEV_TOOLS_MODEL || "openjev",
38
+ }
39
+ : undefined;
40
+ }
41
+ function httpError(body: unknown, text: string, apiKey: string): string {
42
+ const error = isRecord(body) ? (body.error ?? body.message) : undefined;
43
+ const detail =
44
+ typeof error === "string"
45
+ ? error
46
+ : isRecord(error) && typeof error.message === "string"
47
+ ? error.message
48
+ : body === undefined
49
+ ? text
50
+ : "No error message provided.";
51
+ const redacted = detail.replaceAll(apiKey, "[redacted]");
52
+ return redacted.length > HTTP_ERROR_MAX_CHARS
53
+ ? `${truncate(redacted, HTTP_ERROR_MAX_CHARS)}…`
54
+ : redacted;
55
+ }
56
+ function isJson(value: unknown): value is Json {
57
+ return (
58
+ value === null ||
59
+ typeof value === "string" ||
60
+ typeof value === "boolean" ||
61
+ (typeof value === "number" && Number.isFinite(value)) ||
62
+ (Array.isArray(value)
63
+ ? value.every(isJson)
64
+ : isRecord(value) && Object.values(value).every(isJson))
65
+ );
66
+ }
67
+ function probabilities(value: unknown): value is Record<string, number> {
68
+ return (
69
+ isRecord(value) &&
70
+ Object.keys(value).length > 0 &&
71
+ Object.values(value).every(
72
+ (p) => typeof p === "number" && Number.isFinite(p) && p >= 0 && p <= 1,
73
+ )
74
+ );
75
+ }
76
+ function normalize(value: unknown, question: Question): Answer {
77
+ const invalid: Answer = {
78
+ type: "unjudged",
79
+ reason: "Invalid or missing Jev answer.",
80
+ };
81
+ if (!isRecord(value)) return invalid;
82
+ if (question.type === "bool")
83
+ return typeof value.noul === "number" && value.noul >= 0 && value.noul <= 1
84
+ ? { type: "bool", p: value.noul }
85
+ : invalid;
86
+ if (
87
+ typeof value.confidence !== "number" ||
88
+ !Number.isFinite(value.confidence) ||
89
+ !probabilities(value.probabilities)
90
+ )
91
+ return invalid;
92
+ if (question.type === "choice")
93
+ return typeof value.choice === "string" &&
94
+ Object.hasOwn(question.criteria, value.choice) &&
95
+ Object.hasOwn(value.probabilities, value.choice) &&
96
+ Object.keys(value.probabilities).every((key) =>
97
+ Object.hasOwn(question.criteria, key),
98
+ )
99
+ ? {
100
+ type: "choice",
101
+ choice: value.choice,
102
+ confidence: value.confidence,
103
+ probabilities: value.probabilities,
104
+ }
105
+ : invalid;
106
+ return typeof value.score === "number" &&
107
+ Number.isFinite(value.score) &&
108
+ value.score >= 0 &&
109
+ value.score <= question.criteria.length - 1 &&
110
+ isJson(value.legend)
111
+ ? {
112
+ type: "score",
113
+ score: value.score,
114
+ confidence: value.confidence,
115
+ legend: value.legend,
116
+ probabilities: value.probabilities,
117
+ }
118
+ : invalid;
119
+ }
120
+ function metadata(body: unknown): JudgmentMetadata {
121
+ if (!isRecord(body)) return {};
122
+ const usage = body.usage;
123
+ return {
124
+ ...(typeof body.model === "string" ? { model: body.model } : {}),
125
+ ...(isRecord(usage) &&
126
+ typeof usage.input_tokens === "number" &&
127
+ Number.isFinite(usage.input_tokens) &&
128
+ usage.input_tokens >= 0 &&
129
+ typeof usage.cost === "number" &&
130
+ Number.isFinite(usage.cost) &&
131
+ usage.cost >= 0
132
+ ? { usage: { inputTokens: usage.input_tokens, costUsd: usage.cost } }
133
+ : {}),
134
+ };
135
+ }
136
+ function canonical(value: Json): string {
137
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
138
+ if (isRecord(value))
139
+ return `{${Object.keys(value)
140
+ .sort()
141
+ .map((key) => `${JSON.stringify(key)}:${canonical(value[key] as Json)}`)
142
+ .join(",")}}`;
143
+ return JSON.stringify(value);
144
+ }
145
+ function hash(value: Json): string {
146
+ return createHash("sha256").update(canonical(value)).digest("hex");
147
+ }
148
+ function wireQuestions(questions: Record<string, Question>) {
149
+ return Object.fromEntries(
150
+ Object.entries(questions).map(([id, q]) => [
151
+ id,
152
+ {
153
+ ...q,
154
+ type: q.type === "bool" ? "noul" : q.type,
155
+ ...(q.type === "bool"
156
+ ? {
157
+ criteria: {
158
+ true: q.criteria?.true ?? "Yes",
159
+ false: q.criteria?.false ?? "No",
160
+ },
161
+ }
162
+ : {}),
163
+ },
164
+ ]),
165
+ );
166
+ }
167
+ export function createJevClient(
168
+ config: JevConfig,
169
+ injected?: Partial<ClientDependencies>,
170
+ ): JevClient {
171
+ const clock: Clock = {
172
+ now: injected?.now ?? (() => performance.now()),
173
+ sleep:
174
+ injected?.sleep ??
175
+ ((ms, signal) => timerSleep(ms, undefined, { signal })),
176
+ };
177
+ const processPoolKey = Symbol.for("jev-tools.request-pool");
178
+ const processPools = globalThis as typeof globalThis & {
179
+ [processPoolKey]?: Pool;
180
+ };
181
+ if (!injected) processPools[processPoolKey] ??= createPool(clock);
182
+ const pool =
183
+ injected?.pool ??
184
+ (injected ? createPool(clock) : (processPools[processPoolKey] as Pool));
185
+ const request = injected?.fetch ?? fetch;
186
+ const timeoutMs = injected?.timeoutMs ?? TIMEOUT_MS;
187
+ const cache = new Map<string, Answer>();
188
+ const witnessCache = new Map<string, Record<string, Answer>>();
189
+ let generation = 0;
190
+ return {
191
+ clearCache() {
192
+ cache.clear();
193
+ witnessCache.clear();
194
+ generation++;
195
+ },
196
+ async judge(state, questions, options = {}): Promise<Judgment> {
197
+ const planned = prepareBatches(state, questions, options);
198
+ if (!planned.ok) return planned;
199
+ const answers: Record<string, Answer> = {};
200
+ const meta: JudgmentMetadata = {
201
+ calls: 0,
202
+ questions: 0,
203
+ cacheHits: 0,
204
+ cacheRequests: Object.keys(questions).length,
205
+ batches: [],
206
+ };
207
+ const epoch = generation;
208
+ const stateKey = hash(state);
209
+ const keys = new Map(
210
+ Object.entries(questions).map(([id, q]) => [
211
+ id,
212
+ `${stateKey}:${hash(JSON.parse(JSON.stringify(q)) as Json)}:${config.model}`,
213
+ ]),
214
+ );
215
+ const witnesses = options.witnesses ?? [];
216
+ const witnessSet = new Set(witnesses);
217
+ const witnessKey = (group: readonly string[]) =>
218
+ JSON.stringify([
219
+ group.map((id) => keys.get(id)),
220
+ witnesses.map((id) => keys.get(id)),
221
+ ]);
222
+ let stopped: string | undefined;
223
+ let stateChecked = false;
224
+ let failure: string | undefined;
225
+ const addMetadata = (body: unknown) => {
226
+ const next = metadata(body);
227
+ if (next.model) meta.model = next.model;
228
+ if (next.usage) {
229
+ meta.usage ??= { inputTokens: 0, costUsd: 0 };
230
+ meta.usage.inputTokens += next.usage.inputTokens;
231
+ meta.usage.costUsd += next.usage.costUsd;
232
+ options.onUsage?.(next.usage);
233
+ }
234
+ };
235
+ const missing = (ids: readonly string[], reason: string) => {
236
+ const batchAnswers = Object.fromEntries(
237
+ ids.map((id) => [id, { type: "unjudged" as const, reason }]),
238
+ );
239
+ for (const [id, answer] of Object.entries(batchAnswers))
240
+ if (!witnessSet.has(id) || !answers[id]) answers[id] = answer;
241
+ meta.batches?.push({ questionIds: [...ids], answers: batchAnswers });
242
+ };
243
+ const send = async (
244
+ groups: readonly (readonly string[])[],
245
+ diagnostic = false,
246
+ ): Promise<void> => {
247
+ const ids = [...groups.flat(), ...(diagnostic ? [] : witnesses)];
248
+ if (stopped) {
249
+ missing(ids, stopped);
250
+ return;
251
+ }
252
+ const selected = Object.fromEntries(
253
+ ids.map((id) => [id, questions[id] as Question]),
254
+ );
255
+ const payload = JSON.stringify({
256
+ model: config.model,
257
+ state,
258
+ questions: wireQuestions(selected),
259
+ });
260
+ for (let attempt = 0; attempt < REQUEST_ATTEMPTS; attempt++) {
261
+ let release: (() => void) | undefined;
262
+ const timeoutCancellation = new AbortController();
263
+ let retryMs = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt);
264
+ try {
265
+ release = await pool.acquire(options.signal);
266
+ if (stopped) {
267
+ missing(ids, stopped);
268
+ return;
269
+ }
270
+ const admission = options.beforeRequest?.(ids.length);
271
+ if (admission && !admission.ok) {
272
+ // A diagnostic denied admission supplies no judgment batch and
273
+ // must not replace the token refusal with a budget reason.
274
+ if (diagnostic) return;
275
+ stopped = admission.error;
276
+ missing(ids, stopped);
277
+ return;
278
+ }
279
+ const timeout = new AbortController();
280
+ void clock.sleep(timeoutMs, timeoutCancellation.signal).then(
281
+ () => timeout.abort(new Error("Jev request timeout")),
282
+ () => {},
283
+ );
284
+ meta.calls = (meta.calls ?? 0) + 1;
285
+ meta.questions = (meta.questions ?? 0) + ids.length;
286
+ const response = await request(config.url, {
287
+ method: "POST",
288
+ headers: {
289
+ "content-type": "application/json",
290
+ Authorization: `Bearer ${config.apiKey}`,
291
+ },
292
+ body: payload,
293
+ signal: options.signal
294
+ ? AbortSignal.any([options.signal, timeout.signal])
295
+ : timeout.signal,
296
+ });
297
+ const text = await response.text();
298
+ let body: unknown;
299
+ try {
300
+ body = JSON.parse(text);
301
+ } catch {
302
+ body = undefined;
303
+ }
304
+ addMetadata(body);
305
+ release();
306
+ release = undefined;
307
+ if (
308
+ response.status === 400 &&
309
+ text.includes("max_tokens_exceeded")
310
+ ) {
311
+ if (diagnostic) {
312
+ stopped =
313
+ "Jev max_tokens_exceeded: state too large for a single question; remaining questions not judged.";
314
+ return;
315
+ }
316
+ if (ids.length === 1) {
317
+ missing(
318
+ ids,
319
+ "Jev max_tokens_exceeded: state too large for a single question; question not judged.",
320
+ );
321
+ return;
322
+ }
323
+ if (!stateChecked && groups.length > 1) {
324
+ stateChecked = true;
325
+ // Assume token cost grows with serialized question size on the
326
+ // same state. This is not a tokenizer guarantee: non-monotone
327
+ // cases are more conservative than main (unjudged), never
328
+ // more optimistic. The probe answer is discarded, not cached.
329
+ let probeId = ids[0] as string;
330
+ let probeSize = Infinity;
331
+ for (const [id, question] of Object.entries(
332
+ wireQuestions(questions),
333
+ )) {
334
+ const size = JSON.stringify(question).length;
335
+ if (size < probeSize) {
336
+ probeId = id;
337
+ probeSize = size;
338
+ }
339
+ }
340
+ await send([[probeId]], true);
341
+ if (stopped) {
342
+ missing(ids, stopped);
343
+ return;
344
+ }
345
+ }
346
+ const split = splitGroups(groups);
347
+ if (split.ok)
348
+ await Promise.all(split.halves.map((half) => send(half)));
349
+ else missing(ids, split.error);
350
+ return;
351
+ }
352
+ if (response.ok && isRecord(body)) {
353
+ if (diagnostic) return;
354
+ const raw = isRecord(body.answers) ? body.answers : {};
355
+ const batchAnswers = Object.fromEntries(
356
+ ids.map((id) => [
357
+ id,
358
+ normalize(raw[id], selected[id] as Question),
359
+ ]),
360
+ );
361
+ meta.batches?.push({ questionIds: ids, answers: batchAnswers });
362
+ for (const [id, answer] of Object.entries(batchAnswers)) {
363
+ if (!witnessSet.has(id) || !answers[id]) answers[id] = answer;
364
+ if (
365
+ options.cache !== false &&
366
+ !witnessSet.has(id) &&
367
+ epoch === generation &&
368
+ answer.type !== "unjudged"
369
+ )
370
+ cache.set(keys.get(id) as string, structuredClone(answer));
371
+ }
372
+ if (
373
+ witnesses.length > 0 &&
374
+ options.cache !== false &&
375
+ epoch === generation &&
376
+ witnesses.every((id) => batchAnswers[id]?.type !== "unjudged")
377
+ ) {
378
+ for (const group of groups) {
379
+ if (
380
+ group.every((id) => batchAnswers[id]?.type !== "unjudged")
381
+ )
382
+ witnessCache.set(
383
+ witnessKey(group),
384
+ structuredClone(
385
+ Object.fromEntries(
386
+ witnesses.map((id) => [
387
+ id,
388
+ batchAnswers[id] as Answer,
389
+ ]),
390
+ ),
391
+ ),
392
+ );
393
+ }
394
+ }
395
+ return;
396
+ }
397
+ if (diagnostic) return;
398
+ const retryAfter =
399
+ response.status === 429
400
+ ? Number(response.headers.get("retry-after"))
401
+ : 0;
402
+ if (
403
+ Number.isFinite(retryAfter) &&
404
+ retryAfter * 1_000 > RETRY_MAX_MS
405
+ ) {
406
+ const reason = `Jev HTTP 429, retry after ${retryAfter} s`;
407
+ failure ??= reason;
408
+ missing(ids, reason);
409
+ return;
410
+ }
411
+ const reason = response.ok
412
+ ? "Invalid Jev response: expected a JSON object."
413
+ : `Jev HTTP ${response.status}: ${httpError(body, text, config.apiKey)}${response.status === 401 || response.status === 403 ? "; check JEV_TOOLS_API_KEY." : ""}`;
414
+ if (
415
+ !(
416
+ response.status === 408 ||
417
+ response.status === 429 ||
418
+ response.status >= 500
419
+ ) ||
420
+ attempt === REQUEST_ATTEMPTS - 1
421
+ ) {
422
+ failure ??= reason;
423
+ if (response.status === 401 || response.status === 403)
424
+ stopped = reason;
425
+ missing(ids, reason);
426
+ return;
427
+ }
428
+ if (Number.isFinite(retryAfter) && retryAfter >= 0)
429
+ retryMs = Math.max(retryMs, retryAfter * 1_000);
430
+ } catch (error) {
431
+ // A failed diagnostic is not evidence of a token limit. Continue
432
+ // ordinary subdivision without delivering its answer or error.
433
+ if (diagnostic) return;
434
+ const reason = `Jev request failed: ${String(error).replaceAll(config.apiKey, "[redacted]")}`;
435
+ if (options.signal?.aborted || attempt === REQUEST_ATTEMPTS - 1) {
436
+ failure ??= reason;
437
+ missing(ids, reason);
438
+ return;
439
+ }
440
+ } finally {
441
+ release?.();
442
+ timeoutCancellation.abort();
443
+ }
444
+ try {
445
+ await clock.sleep(retryMs, options.signal);
446
+ } catch (error) {
447
+ const reason = `Jev request failed: ${String(error).replaceAll(config.apiKey, "[redacted]")}`;
448
+ failure ??= reason;
449
+ missing(ids, reason);
450
+ return;
451
+ }
452
+ }
453
+ };
454
+ const pending = planned.batches
455
+ .map((batch) =>
456
+ batch.filter((group) => {
457
+ if (
458
+ options.cache === false ||
459
+ !group.every((id) => cache.has(keys.get(id) as string)) ||
460
+ (witnesses.length > 0 && !witnessCache.has(witnessKey(group)))
461
+ )
462
+ return true;
463
+ for (const id of group) {
464
+ answers[id] = structuredClone(
465
+ cache.get(keys.get(id) as string) as Answer,
466
+ );
467
+ meta.cacheHits = (meta.cacheHits ?? 0) + 1;
468
+ }
469
+ const observedWitnesses = structuredClone(
470
+ witnessCache.get(witnessKey(group)) ?? {},
471
+ );
472
+ for (const [id, answer] of Object.entries(observedWitnesses)) {
473
+ if (!answers[id]) {
474
+ answers[id] = answer;
475
+ meta.cacheHits = (meta.cacheHits ?? 0) + 1;
476
+ }
477
+ }
478
+ const cachedIds = [...group, ...witnesses];
479
+ meta.batches?.push({
480
+ questionIds: cachedIds,
481
+ answers: Object.fromEntries(
482
+ cachedIds.map((id) => [
483
+ id,
484
+ witnessSet.has(id)
485
+ ? (observedWitnesses[id] as Answer)
486
+ : structuredClone(
487
+ cache.get(keys.get(id) as string) as Answer,
488
+ ),
489
+ ]),
490
+ ),
491
+ });
492
+ return false;
493
+ }),
494
+ )
495
+ .filter((batch) => batch.length);
496
+ await Promise.all(pending.map((batch) => send(batch)));
497
+ if (
498
+ failure &&
499
+ !Object.values(answers).some((answer) => answer.type !== "unjudged")
500
+ )
501
+ return { ok: false, error: failure, ...meta };
502
+ return { ok: true, answers, ...meta };
503
+ },
504
+ };
505
+ }
@@ -0,0 +1,60 @@
1
+ import { CONCURRENCY, RATE_PER_SECOND } from "../constants.ts";
2
+
3
+ export interface Clock {
4
+ now(): number;
5
+ sleep(ms: number, signal?: AbortSignal): Promise<void>;
6
+ }
7
+ export interface Pool {
8
+ acquire(signal?: AbortSignal): Promise<() => void>;
9
+ }
10
+
11
+ /** A fresh pool owns its sliding window; share it between clients in one process. */
12
+ export function createPool(clock: Clock): Pool {
13
+ let active = 0;
14
+ const starts: number[] = [];
15
+ let gate = Promise.resolve();
16
+ let released = Promise.withResolvers<void>();
17
+ return {
18
+ async acquire(signal) {
19
+ const previous = gate;
20
+ const turn = Promise.withResolvers<void>();
21
+ gate = turn.promise;
22
+ await previous;
23
+ try {
24
+ for (;;) {
25
+ signal?.throwIfAborted();
26
+ const now = clock.now();
27
+ while (starts.length && now - (starts[0] ?? now) >= 1_000)
28
+ starts.shift();
29
+ if (active >= CONCURRENCY) {
30
+ const cancelled = Promise.withResolvers<void>();
31
+ const abort = () => cancelled.reject(signal?.reason);
32
+ signal?.addEventListener("abort", abort, { once: true });
33
+ try {
34
+ await Promise.race([released.promise, cancelled.promise]);
35
+ } finally {
36
+ signal?.removeEventListener("abort", abort);
37
+ }
38
+ } else if (starts.length >= RATE_PER_SECOND) {
39
+ await clock.sleep(
40
+ Math.max(1, 1_000 - (now - (starts[0] ?? now))),
41
+ signal,
42
+ );
43
+ } else break;
44
+ }
45
+ active++;
46
+ starts.push(clock.now());
47
+ let done = false;
48
+ return () => {
49
+ if (done) return;
50
+ done = true;
51
+ active--;
52
+ released.resolve();
53
+ released = Promise.withResolvers<void>();
54
+ };
55
+ } finally {
56
+ turn.resolve();
57
+ }
58
+ },
59
+ };
60
+ }