@visual-z/xeno 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.
- package/README.md +498 -0
- package/docs/install.md +130 -0
- package/package.json +43 -0
- package/src/apps/approve.ts +49 -0
- package/src/apps/cli.ts +37 -0
- package/src/apps/commands.ts +226 -0
- package/src/apps/completions.ts +28 -0
- package/src/apps/input.ts +297 -0
- package/src/apps/render.ts +207 -0
- package/src/apps/shell.ts +2353 -0
- package/src/apps/startup.ts +1302 -0
- package/src/core/benchmark.ts +195 -0
- package/src/core/boundary.ts +174 -0
- package/src/core/churn.ts +59 -0
- package/src/core/completion-review.ts +66 -0
- package/src/core/context-policy.ts +60 -0
- package/src/core/context-window.ts +54 -0
- package/src/core/continuation.ts +114 -0
- package/src/core/cycle.ts +17 -0
- package/src/core/data-query.ts +201 -0
- package/src/core/deadline.ts +39 -0
- package/src/core/derive.ts +489 -0
- package/src/core/edit-plan.ts +200 -0
- package/src/core/environment.ts +29 -0
- package/src/core/event-validation.ts +99 -0
- package/src/core/events.ts +486 -0
- package/src/core/evidence-links.ts +71 -0
- package/src/core/evidence.ts +89 -0
- package/src/core/execution.ts +12 -0
- package/src/core/exhaustive.ts +18 -0
- package/src/core/experiment.ts +40 -0
- package/src/core/export.ts +169 -0
- package/src/core/file-references.ts +35 -0
- package/src/core/frontmatter.ts +213 -0
- package/src/core/handoff.ts +397 -0
- package/src/core/harness-comparison.ts +90 -0
- package/src/core/history-query.ts +103 -0
- package/src/core/history.ts +100 -0
- package/src/core/json-event.ts +135 -0
- package/src/core/json-pointer.ts +46 -0
- package/src/core/keymap.ts +241 -0
- package/src/core/observation-view.ts +87 -0
- package/src/core/outcome.ts +45 -0
- package/src/core/overflow.ts +59 -0
- package/src/core/permissions.ts +182 -0
- package/src/core/policy-evaluation.ts +55 -0
- package/src/core/progress.ts +101 -0
- package/src/core/project.ts +372 -0
- package/src/core/prompts.ts +83 -0
- package/src/core/queue.ts +60 -0
- package/src/core/read-bounds.ts +15 -0
- package/src/core/recall-analysis.ts +90 -0
- package/src/core/recall-trial.ts +153 -0
- package/src/core/response-recovery.ts +46 -0
- package/src/core/scheduling.ts +8 -0
- package/src/core/settings.ts +174 -0
- package/src/core/skills.ts +121 -0
- package/src/core/stall.ts +48 -0
- package/src/core/text-normalize.ts +81 -0
- package/src/core/theme.ts +179 -0
- package/src/core/tokens.ts +149 -0
- package/src/core/tool-contract.ts +102 -0
- package/src/core/tool-input.ts +38 -0
- package/src/core/transcript.ts +60 -0
- package/src/core/window-trial.ts +65 -0
- package/src/core/work-state.ts +103 -0
- package/src/core/yaml.ts +282 -0
- package/src/index.ts +10 -0
- package/src/runtime/auth.ts +255 -0
- package/src/runtime/benchmark.ts +187 -0
- package/src/runtime/catalog.ts +365 -0
- package/src/runtime/evidence-notes.ts +33 -0
- package/src/runtime/exec-sessions.ts +214 -0
- package/src/runtime/execution-control.ts +39 -0
- package/src/runtime/harness-comparison.ts +356 -0
- package/src/runtime/history-tools.ts +108 -0
- package/src/runtime/loop.ts +1983 -0
- package/src/runtime/model.ts +614 -0
- package/src/runtime/models-fetch.ts +155 -0
- package/src/runtime/oauth-device.ts +86 -0
- package/src/runtime/oauth.ts +759 -0
- package/src/runtime/openai-responses.ts +286 -0
- package/src/runtime/openai.ts +675 -0
- package/src/runtime/provider.ts +498 -0
- package/src/runtime/recall-trial.ts +226 -0
- package/src/runtime/resume.ts +66 -0
- package/src/runtime/retry.ts +297 -0
- package/src/runtime/tools.ts +1193 -0
- package/src/runtime/usage.ts +225 -0
- package/src/runtime/window-trial.ts +140 -0
- package/src/runtime/working-notes.ts +30 -0
- package/src/store/atomic.ts +31 -0
- package/src/store/auth-store.ts +50 -0
- package/src/store/blobs.ts +142 -0
- package/src/store/command.ts +75 -0
- package/src/store/config.ts +69 -0
- package/src/store/context-files.ts +356 -0
- package/src/store/file-index.ts +81 -0
- package/src/store/git-branch.ts +35 -0
- package/src/store/history-search.ts +19 -0
- package/src/store/log.ts +642 -0
- package/src/store/models.ts +41 -0
- package/src/store/observations.ts +181 -0
- package/src/store/query-source.ts +44 -0
- package/src/store/recovery.ts +39 -0
- package/src/store/replay.ts +63 -0
- package/src/store/resources.ts +361 -0
- package/src/store/restore.ts +302 -0
- package/src/store/search.ts +644 -0
- package/src/store/serial.ts +13 -0
- package/src/store/sessions.ts +463 -0
- package/src/store/trust.ts +69 -0
- package/src/store/workspace.ts +256 -0
- package/src/store/worktree.ts +233 -0
- package/src/tui/ansi.ts +67 -0
- package/src/tui/autocomplete.ts +29 -0
- package/src/tui/complete.ts +87 -0
- package/src/tui/diff.ts +160 -0
- package/src/tui/editor.ts +893 -0
- package/src/tui/exhaustive.ts +14 -0
- package/src/tui/fuzzy.ts +75 -0
- package/src/tui/host.ts +223 -0
- package/src/tui/input.ts +69 -0
- package/src/tui/keys.ts +378 -0
- package/src/tui/markdown.ts +17 -0
- package/src/tui/render.ts +153 -0
- package/src/tui/select.ts +240 -0
- package/src/tui/settings.ts +40 -0
- package/src/tui/statusline.ts +162 -0
- package/src/tui/style.ts +205 -0
- package/src/tui/terminal.ts +30 -0
- package/src/tui/text.ts +52 -0
- package/src/tui/toolview.ts +386 -0
- package/src/tui/transcript.ts +71 -0
- package/src/tui/tree.ts +405 -0
- package/src/tui/width.ts +567 -0
- package/src/vendor/pi-tui/LICENSE.upstream +21 -0
- package/src/vendor/pi-tui/VENDOR.md +23 -0
- package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
- package/src/vendor/pi-tui/autocomplete.ts +835 -0
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
- package/src/vendor/pi-tui/components/box.ts +167 -0
- package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
- package/src/vendor/pi-tui/components/editor.ts +2470 -0
- package/src/vendor/pi-tui/components/h-stack.ts +44 -0
- package/src/vendor/pi-tui/components/image.ts +127 -0
- package/src/vendor/pi-tui/components/input.ts +494 -0
- package/src/vendor/pi-tui/components/loader.ts +101 -0
- package/src/vendor/pi-tui/components/markdown.ts +1015 -0
- package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
- package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
- package/src/vendor/pi-tui/components/select-list.ts +273 -0
- package/src/vendor/pi-tui/components/settings-list.ts +328 -0
- package/src/vendor/pi-tui/components/spacer.ts +28 -0
- package/src/vendor/pi-tui/components/stack.ts +154 -0
- package/src/vendor/pi-tui/components/text.ts +107 -0
- package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
- package/src/vendor/pi-tui/components/v-stack.ts +33 -0
- package/src/vendor/pi-tui/editor-component.ts +74 -0
- package/src/vendor/pi-tui/fuzzy.ts +138 -0
- package/src/vendor/pi-tui/index.ts +156 -0
- package/src/vendor/pi-tui/keybindings.ts +320 -0
- package/src/vendor/pi-tui/keys.ts +1401 -0
- package/src/vendor/pi-tui/kill-ring.ts +46 -0
- package/src/vendor/pi-tui/latex.ts +1506 -0
- package/src/vendor/pi-tui/layout-node.ts +51 -0
- package/src/vendor/pi-tui/layout.ts +449 -0
- package/src/vendor/pi-tui/native-modifiers.ts +13 -0
- package/src/vendor/pi-tui/native-module-path.ts +31 -0
- package/src/vendor/pi-tui/native-platform.ts +63 -0
- package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
- package/src/vendor/pi-tui/terminal-colors.ts +73 -0
- package/src/vendor/pi-tui/terminal-image.ts +696 -0
- package/src/vendor/pi-tui/terminal.ts +547 -0
- package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
- package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
- package/src/vendor/pi-tui/tui.ts +1456 -0
- package/src/vendor/pi-tui/undo-stack.ts +28 -0
- package/src/vendor/pi-tui/utils.ts +1345 -0
- package/src/vendor/pi-tui/word-navigation.ts +117 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F2 delayed recall across three harnesses behind one metered gateway.
|
|
3
|
+
*
|
|
4
|
+
* Each page is deleted when its stage ends, so a later question can only be
|
|
5
|
+
* answered from what the harness itself retained. The arms differ in exactly
|
|
6
|
+
* that: xeno keeps a lossless log with a `history` reader; the ablation keeps
|
|
7
|
+
* the same windowed projection without the reader; Pi keeps its own compaction
|
|
8
|
+
* summary. See docs/tasks/bench-recall-runner.md.
|
|
9
|
+
*/
|
|
10
|
+
import { appendFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, join, relative } from "node:path";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { recallCodes, recallPrompt, verifyRecallAnswer, type RecallFixture, type RecallKind, type RecallVerdict } from "../core/recall-trial.ts";
|
|
14
|
+
import type { BenchmarkPrice, BenchmarkUsage } from "../core/benchmark.ts";
|
|
15
|
+
import { Session } from "./loop.ts";
|
|
16
|
+
import { OpenAICompletionsProvider } from "./openai.ts";
|
|
17
|
+
import type { ProviderConfig, ToolSchema } from "./provider.ts";
|
|
18
|
+
import { COMPARISON_SYSTEM, COMPARISON_TOOLS, comparisonGateway, runPiPrompt, type ComparisonBackend, type ComparisonLedger, type GatewayRecord, type PiIdentity } from "./harness-comparison.ts";
|
|
19
|
+
import { DEFAULT_TOOL_CONTEXT, TOOL_SCHEMAS, runTool } from "./tools.ts";
|
|
20
|
+
|
|
21
|
+
export const RECALL_ARMS = ["xeno", "xeno-nohistory", "pi"] as const;
|
|
22
|
+
export type RecallArm = typeof RECALL_ARMS[number];
|
|
23
|
+
|
|
24
|
+
/** Native memory tools are the independent variable; everything else is shared. */
|
|
25
|
+
export function recallToolNames(arm: RecallArm): readonly string[] {
|
|
26
|
+
return arm === "xeno" ? [...COMPARISON_TOOLS, "history", "note"] : COMPARISON_TOOLS;
|
|
27
|
+
}
|
|
28
|
+
export const recallTools = (arm: RecallArm): ToolSchema[] =>
|
|
29
|
+
recallToolNames(arm).map((name) => TOOL_SCHEMAS.find((tool) => tool.name === name)!);
|
|
30
|
+
|
|
31
|
+
export interface RecallStageResult {
|
|
32
|
+
stage: number; kind: RecallKind; source: number; distance: number;
|
|
33
|
+
/** A context boundary (xeno cut / Pi compaction) separated the source page from the answer. */
|
|
34
|
+
crossed: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* The expected code was in the first request of this stage. A crossed
|
|
37
|
+
* boundary does not imply loss (xeno retains recent exchanges, a Pi summary
|
|
38
|
+
* may copy a code); this measures loss on the wire, identically per arm.
|
|
39
|
+
*/
|
|
40
|
+
visibleAtAsk: boolean | null;
|
|
41
|
+
/** Visible in every task request of the stage; a mid-stage boundary can evict it after the ask. Null for recent. */
|
|
42
|
+
visibleThroughout: boolean | null;
|
|
43
|
+
status: "answered" | "stopped" | "error"; reason: string; verdict: RecallVerdict;
|
|
44
|
+
requests: number; pageReads: number; historyCalls: number; boundariesAfter: number;
|
|
45
|
+
}
|
|
46
|
+
export interface RecallTrialResult {
|
|
47
|
+
arm: RecallArm; planned: number; unrun: number; stages: RecallStageResult[];
|
|
48
|
+
toolNames: string[]; boundaries: number; requests: number;
|
|
49
|
+
knownCost: number; reservedUnknown: number; usage: BenchmarkUsage; peakPrompt: number;
|
|
50
|
+
/** Files other than answers the model created: its own notes, reported separately. */
|
|
51
|
+
modelFiles: string[];
|
|
52
|
+
/** Workspace copies of deleted pages; must be empty for the stage to count as observed-once. */
|
|
53
|
+
leakedPages: string[];
|
|
54
|
+
stopped: "budget" | "balance" | "error" | "limit" | null; wallMs: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sha = (body: string) => createHash("sha256").update(body).digest("hex");
|
|
58
|
+
|
|
59
|
+
async function listFiles(root: string): Promise<string[]> {
|
|
60
|
+
const out: string[] = [];
|
|
61
|
+
for (const entry of await readdir(root, { withFileTypes: true, recursive: true })) {
|
|
62
|
+
if (entry.isFile()) out.push(relative(root, join(entry.parentPath, entry.name)));
|
|
63
|
+
}
|
|
64
|
+
return out.sort();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function runRecallTrial(options: {
|
|
68
|
+
directory: string; fixture: RecallFixture; arm: RecallArm;
|
|
69
|
+
config: ProviderConfig; price: BenchmarkPrice; ledger: ComparisonLedger; backend: ComparisonBackend;
|
|
70
|
+
maxTurnsPerStage: number; stageTimeoutMs: number;
|
|
71
|
+
pi: PiIdentity | null; extension: string;
|
|
72
|
+
/** Synthetic pressure only; live trials keep Pi's default compaction. */
|
|
73
|
+
piCompaction?: { reserveTokens: number; keepRecentTokens: number };
|
|
74
|
+
onProgress?: (event: Record<string, unknown>) => Promise<void>;
|
|
75
|
+
}): Promise<RecallTrialResult> {
|
|
76
|
+
const { fixture, arm } = options, started = performance.now();
|
|
77
|
+
const workspace = join(options.directory, "workspace"), outputStore = join(options.directory, "observations");
|
|
78
|
+
await mkdir(workspace, { recursive: true }); await mkdir(outputStore, { recursive: true });
|
|
79
|
+
const tools = recallTools(arm), allow = recallToolNames(arm);
|
|
80
|
+
const result: RecallTrialResult = { arm, planned: fixture.pages.length, unrun: fixture.pages.length, stages: [],
|
|
81
|
+
toolNames: [...allow], boundaries: 0, requests: 0, knownCost: 0, reservedUnknown: 0,
|
|
82
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, peakPrompt: 0,
|
|
83
|
+
modelFiles: [], leakedPages: [], stopped: null, wallMs: 0 };
|
|
84
|
+
let stage = 0, pageReads = 0, historyCalls = 0, stageTaskRequests = 0;
|
|
85
|
+
let seen: boolean[] = [];
|
|
86
|
+
const page = () => fixture.pages[stage]!;
|
|
87
|
+
const countCall = (name: string, input: unknown) => {
|
|
88
|
+
if (name === "history") historyCalls++;
|
|
89
|
+
if (name === "read" && input !== null && typeof input === "object" && (input as Record<string, unknown>)["path"] === page().path) pageReads++;
|
|
90
|
+
};
|
|
91
|
+
const toolContext = { ...DEFAULT_TOOL_CONTEXT, root: workspace, allow, outputStore };
|
|
92
|
+
const gateway = comparisonGateway({ config: options.config, price: options.price, ledger: options.ledger, tools,
|
|
93
|
+
// Pi's summary calls share this ceiling; the slack keeps them from starving task turns.
|
|
94
|
+
maxRequests: (options.maxTurnsPerStage + 2) * fixture.pages.length,
|
|
95
|
+
// The same per-stage cap for every arm: Session enforces maxTurns itself,
|
|
96
|
+
// Pi has no such flag, so the gateway refuses its excess task requests.
|
|
97
|
+
admit: (hasTools) => !hasTools || stageTaskRequests++ < options.maxTurnsPerStage,
|
|
98
|
+
backend: (payload, signal) => {
|
|
99
|
+
// Summary calls carry no tools; only a task turn shows what the model sees.
|
|
100
|
+
if (Array.isArray(payload["tools"])) seen.push(JSON.stringify(payload["messages"]).replaceAll("\\", "").includes(page().question.expected));
|
|
101
|
+
return options.backend(payload, signal);
|
|
102
|
+
},
|
|
103
|
+
executeTool: (name, input) => { countCall(name, input); return runTool(toolContext, name, input); },
|
|
104
|
+
record: async (record: GatewayRecord) => {
|
|
105
|
+
await appendFile(join(options.directory, "requests.jsonl"), JSON.stringify({ stage, ...record }) + "\n");
|
|
106
|
+
if (record.kind === "settled") {
|
|
107
|
+
result.requests++;
|
|
108
|
+
if (record.measured === null) result.reservedUnknown += record.charge; else result.knownCost += record.measured;
|
|
109
|
+
if (record.usage) {
|
|
110
|
+
for (const key of Object.keys(result.usage) as (keyof BenchmarkUsage)[]) result.usage[key] += record.usage[key];
|
|
111
|
+
result.peakPrompt = Math.max(result.peakPrompt, record.usage.input_tokens + record.usage.cache_read_input_tokens);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
await options.onProgress?.({ phase: record.kind, arm, stage, request: record.request, measured: record.measured, failure: record.failure });
|
|
115
|
+
} });
|
|
116
|
+
const config = { ...options.config, spec: { ...options.config.spec, baseUrl: gateway.url } };
|
|
117
|
+
const codes = recallCodes(fixture), boundaryAfter: number[] = [];
|
|
118
|
+
let session: Session | null = null;
|
|
119
|
+
try {
|
|
120
|
+
if (arm !== "pi") {
|
|
121
|
+
await mkdir(join(options.directory, "log"));
|
|
122
|
+
const provider = new OpenAICompletionsProvider(config, gateway.token, { retry: { maxRetries: 0, maxRetryDelayMs: 0 } });
|
|
123
|
+
session = new Session({ logDir: join(options.directory, "log"), frozen: { system: COMPARISON_SYSTEM, tools },
|
|
124
|
+
tools: toolContext, contextPolicy: "windowed", maxTurns: options.maxTurnsPerStage, harnessReviews: false }, provider);
|
|
125
|
+
await session.start();
|
|
126
|
+
} else if (!options.pi) throw new Error("Pi identity required");
|
|
127
|
+
for (const current of fixture.pages) {
|
|
128
|
+
if (options.ledger.halted || gateway.stopped) break;
|
|
129
|
+
stage = current.stage; pageReads = 0; historyCalls = 0; seen = []; stageTaskRequests = 0;
|
|
130
|
+
const file = join(workspace, current.path);
|
|
131
|
+
await mkdir(dirname(file), { recursive: true }); await mkdir(join(workspace, "answers"), { recursive: true });
|
|
132
|
+
await writeFile(file, current.body, { flag: "wx" });
|
|
133
|
+
const requestsBefore = result.requests, signal = AbortSignal.timeout(options.stageTimeoutMs);
|
|
134
|
+
let status: RecallStageResult["status"] = "stopped", reason = "no completion";
|
|
135
|
+
try {
|
|
136
|
+
if (session) {
|
|
137
|
+
for await (const event of session.send(recallPrompt(fixture, stage), signal)) {
|
|
138
|
+
if (event.kind === "cut") result.boundaries++;
|
|
139
|
+
if (event.kind === "tool_call" && event.tool) countCall(event.tool.name, event.tool.input);
|
|
140
|
+
if (event.kind === "done") reason = event.reason ?? "unknown completion";
|
|
141
|
+
}
|
|
142
|
+
if (["stop", "end_turn"].includes(reason)) status = "answered";
|
|
143
|
+
} else {
|
|
144
|
+
const run = await runPiPrompt({ directory: options.directory, workspace, prompt: recallPrompt(fixture, stage), config,
|
|
145
|
+
gateway, price: options.price, pi: options.pi!, extension: options.extension, signal, resume: stage > 0, tools,
|
|
146
|
+
...(options.piCompaction ? { piCompaction: options.piCompaction } : {}) });
|
|
147
|
+
result.boundaries += run.cuts; reason = run.reason; status = run.answered ? "answered" : "error";
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
status = "error"; reason = signal.aborted ? "stage deadline" : gateway.stopped ? `model gateway: ${gateway.stopped}` : "stage failed";
|
|
151
|
+
}
|
|
152
|
+
boundaryAfter[stage] = result.boundaries;
|
|
153
|
+
const answer = await readFile(join(workspace, current.answerPath), "utf8").catch(() => null);
|
|
154
|
+
const verdict = verifyRecallAnswer(answer, current.question, codes);
|
|
155
|
+
await rm(file);
|
|
156
|
+
const { question } = current;
|
|
157
|
+
const row: RecallStageResult = { stage, kind: question.kind, source: question.source, distance: stage - question.source,
|
|
158
|
+
crossed: question.source < stage && result.boundaries > boundaryAfter[question.source]!,
|
|
159
|
+
visibleAtAsk: question.source === stage || seen.length === 0 ? null : seen[0]!,
|
|
160
|
+
visibleThroughout: question.source === stage || seen.length === 0 ? null : seen.every(Boolean),
|
|
161
|
+
status, reason, verdict, requests: result.requests - requestsBefore, pageReads, historyCalls, boundariesAfter: result.boundaries };
|
|
162
|
+
result.stages.push(row); result.unrun = result.planned - result.stages.length;
|
|
163
|
+
await writeFile(join(options.directory, `stage-${String(stage).padStart(3, "0")}.json`),
|
|
164
|
+
JSON.stringify({ ...row, answer }, null, 2) + "\n", { flag: "wx" });
|
|
165
|
+
await options.onProgress?.({ phase: "stage", arm, stage, verdict: verdict.reason, crossed: row.crossed, boundaries: result.boundaries });
|
|
166
|
+
}
|
|
167
|
+
} finally { await gateway.close(); }
|
|
168
|
+
const files = await listFiles(workspace), bodies = new Set(fixture.pages.map((p) => sha(p.body)));
|
|
169
|
+
result.modelFiles = files.filter((path) => !path.startsWith("answers/") && !path.startsWith("incoming/"));
|
|
170
|
+
for (const path of files) if (bodies.has(sha(await readFile(join(workspace, path), "utf8")))) result.leakedPages.push(path);
|
|
171
|
+
result.stopped = gateway.stopped; result.wallMs = performance.now() - started;
|
|
172
|
+
await writeFile(join(options.directory, "result.json"), JSON.stringify(result, null, 2) + "\n", { flag: "wx" });
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Mechanical backend: answers only from what the request actually shows.
|
|
178
|
+
*
|
|
179
|
+
* It cannot remember across requests, so a code it finds proves the
|
|
180
|
+
* information was in that projection (or recovered by history). Its summary
|
|
181
|
+
* text for Pi is invented here, which is why nothing it produces is a score.
|
|
182
|
+
*/
|
|
183
|
+
export function recallMechanicalBackend(): ComparisonBackend {
|
|
184
|
+
let id = 0;
|
|
185
|
+
return async (payload) => {
|
|
186
|
+
const messages = payload["messages"] as Record<string, unknown>[], tools = payload["tools"] as { function: { name: string } }[] | undefined;
|
|
187
|
+
const reply = (content: string | null, call?: { name: string; input: unknown }) => ({
|
|
188
|
+
id: `recall-mock-${++id}`, object: "chat.completion", created: 1, model: "recall-mechanical",
|
|
189
|
+
choices: [{ index: 0, finish_reason: call ? "tool_calls" : "stop", message: { role: "assistant", content,
|
|
190
|
+
...(call ? { tool_calls: [{ id: `call-${id}`, type: "function", function: { name: call.name, arguments: JSON.stringify(call.input) } }] } : {}) } }],
|
|
191
|
+
usage: { prompt_tokens: Math.ceil(JSON.stringify([tools ?? [], messages]).length / 3.6), completion_tokens: 64, prompt_cache_hit_tokens: 0 },
|
|
192
|
+
});
|
|
193
|
+
if (!tools) return reply("Checkpoint: staged pages processed and answers written.");
|
|
194
|
+
const text = (m: Record<string, unknown>) => typeof m["content"] === "string" ? m["content"] : JSON.stringify(m["content"] ?? "");
|
|
195
|
+
const at = messages.findLastIndex((m) => m["role"] === "user" && /Stage \d+: read /.test(text(m)));
|
|
196
|
+
// After a cut the current request can sit inside a handoff that also quotes
|
|
197
|
+
// earlier stages; the latest quoted stage is the active one.
|
|
198
|
+
const asks = [...text(messages[at]!).matchAll(/read (\S+) in full\. Question: what is the current code of ticket (T-\d{5})\? Write only the code to (\S+),/g)];
|
|
199
|
+
const [, path, ticket, answerPath] = asks.at(-1)!;
|
|
200
|
+
const calls = messages.slice(at + 1).flatMap((m) => (m["tool_calls"] as { function: { name: string; arguments: string } }[] | undefined) ?? [])
|
|
201
|
+
.map((c) => ({ name: c.function.name, input: JSON.parse(c.function.arguments) as Record<string, unknown> }));
|
|
202
|
+
if (calls.some((c) => c.name === "write")) return reply("Stage done.");
|
|
203
|
+
if (!calls.some((c) => c.name === "read" && c.input["path"] === path)) return reply(null, { name: "read", input: { path } });
|
|
204
|
+
// Backslashes vanish so nested JSON (history results) matches the same way.
|
|
205
|
+
const visible = JSON.stringify(messages).replaceAll("\\", "");
|
|
206
|
+
const pattern = new RegExp(`"ticket":"${ticket}","code":"(K-[0-9a-f]{12})"`, "g");
|
|
207
|
+
// Search results are newest first, so the first hit there is the latest code;
|
|
208
|
+
// in the conversation itself the last occurrence is the latest.
|
|
209
|
+
const search = messages.slice(at + 1).findLast((m) => m["role"] === "tool" && text(m).includes('"matches"'));
|
|
210
|
+
const found = (search ? [...text(search).replaceAll("\\", "").matchAll(pattern)][0]?.[1] : undefined)
|
|
211
|
+
?? [...visible.matchAll(pattern)].at(-1)?.[1];
|
|
212
|
+
if (found) return reply(null, { name: "write", input: { path: answerPath, content: found } });
|
|
213
|
+
const names = new Set(tools.map((t) => t.function.name));
|
|
214
|
+
const history = calls.filter((c) => c.name === "history");
|
|
215
|
+
if (names.has("history") && history.length === 0) return reply(null, { name: "history", input: { query: ticket } });
|
|
216
|
+
if (names.has("history") && history.length === 1) {
|
|
217
|
+
// Pick an observation that read a page; the question prompt also matches the ticket.
|
|
218
|
+
const result = messages.slice(at + 1).findLast((m) => m["role"] === "tool" && text(m).includes('"matches"'));
|
|
219
|
+
let items: { event: string; tool?: string }[] = [];
|
|
220
|
+
try { items = (JSON.parse(text(result ?? {})) as { items: typeof items }).items; } catch { /* not a search page */ }
|
|
221
|
+
const event = items.find((item) => item.tool === "read")?.event;
|
|
222
|
+
if (event) return reply(null, { name: "history", input: { event } });
|
|
223
|
+
}
|
|
224
|
+
return reply(null, { name: "write", input: { path: answerPath, content: "UNKNOWN" } });
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picking a session back up where it stopped.
|
|
3
|
+
*
|
|
4
|
+
* Pure: it decides, given a recorded cursor and the model about to run, what
|
|
5
|
+
* should happen. Doing the reading and the refusing in separate places keeps
|
|
6
|
+
* the one interesting rule -- when a resume is unsafe -- testable without a
|
|
7
|
+
* filesystem.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Cursor } from "../store/log.ts";
|
|
11
|
+
|
|
12
|
+
export type ResumePlan =
|
|
13
|
+
| { kind: "fresh" }
|
|
14
|
+
| { kind: "resume"; cursor: Cursor }
|
|
15
|
+
| { kind: "refuse"; reason: string };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A session's thinking blocks belong to the model that produced them.
|
|
19
|
+
*
|
|
20
|
+
* Every provider this harness talks to requires them replayed unchanged, and
|
|
21
|
+
* the two do not use the same representation at all: DeepSeek carries
|
|
22
|
+
* `reasoning_content`, Anthropic carries a signed block. Handing one to the
|
|
23
|
+
* other is a bad request, and it would surface on the first turn of the
|
|
24
|
+
* resumed session rather than here.
|
|
25
|
+
*
|
|
26
|
+
* Refusing rather than converting is the point. pi converts, wrapping a
|
|
27
|
+
* foreign thinking trace in `<thinking>` tags inside assistant content -- a
|
|
28
|
+
* lossy, opinionated rewrite of what the model is told happened. This
|
|
29
|
+
* project's position is that the context is a recomputable projection of the
|
|
30
|
+
* log, so a resume that silently rewrites history breaks the one property
|
|
31
|
+
* everything else rests on. The log is intact; switching back to the model
|
|
32
|
+
* that wrote it picks up exactly where it left off.
|
|
33
|
+
*/
|
|
34
|
+
export function planResume(
|
|
35
|
+
cursor: Cursor | null,
|
|
36
|
+
model: string,
|
|
37
|
+
hasNodes: boolean,
|
|
38
|
+
): ResumePlan {
|
|
39
|
+
if (cursor === null) {
|
|
40
|
+
// No cursor and no nodes is simply a new session. No cursor but nodes on
|
|
41
|
+
// disk is a log this build cannot place itself in -- written before the
|
|
42
|
+
// cursor existed, or with a manifest that was lost. Refused rather than
|
|
43
|
+
// guessed: the whole reason the cursor is recorded is that the tree shape
|
|
44
|
+
// cannot be read backwards to find it, and inventing an answer here would
|
|
45
|
+
// reintroduce exactly the ambiguity it exists to remove.
|
|
46
|
+
return hasNodes
|
|
47
|
+
? {
|
|
48
|
+
kind: "refuse",
|
|
49
|
+
reason:
|
|
50
|
+
"this log has nodes but no recorded cursor, so there is no way to " +
|
|
51
|
+
"know which one the session was in. It is intact and nothing will " +
|
|
52
|
+
"be touched -- start a separate session with --fresh",
|
|
53
|
+
}
|
|
54
|
+
: { kind: "fresh" };
|
|
55
|
+
}
|
|
56
|
+
if (cursor.model !== model) {
|
|
57
|
+
return {
|
|
58
|
+
kind: "refuse",
|
|
59
|
+
reason:
|
|
60
|
+
`this session ran under ${cursor.model}, and ${model} cannot replay its ` +
|
|
61
|
+
`thinking blocks. Resume it with --model ${cursor.model}, or start a ` +
|
|
62
|
+
`separate session with --fresh`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { kind: "resume", cursor };
|
|
66
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a failed request is worth making again.
|
|
3
|
+
*
|
|
4
|
+
* Split into a pure decision and a thin loop, because the decision is where
|
|
5
|
+
* all the rules are and none of them need a clock. What to retry, how long to
|
|
6
|
+
* wait, when to stop -- all of it is testable in microseconds, which is the
|
|
7
|
+
* difference between pinning the policy and hoping it works.
|
|
8
|
+
*
|
|
9
|
+
* The rules mirror the OpenAI and Anthropic SDKs' own, by way of pi, which
|
|
10
|
+
* notes the same lineage. That is deliberate: the failure modes were found by
|
|
11
|
+
* people running far more traffic than this, and inventing a different policy
|
|
12
|
+
* would mean rediscovering them.
|
|
13
|
+
*
|
|
14
|
+
* Retry lives at the request, inside the adapter, and not in the loop. A retry
|
|
15
|
+
* in the loop would straddle a log append -- the failed attempt would either
|
|
16
|
+
* be recorded, polluting an append-only history with something that did not
|
|
17
|
+
* happen, or dropped, leaving usage that does not add up. The loop should see
|
|
18
|
+
* a result that has already been retried, or a definite failure.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { isContextOverflow } from "../core/overflow.ts";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Words providers and relays use for "try again": timeouts, unavailable or
|
|
25
|
+
* overloaded upstreams, rate limits, dropped connections, 5xx codes. A
|
|
26
|
+
* pattern, not a list of known providers, because relays forward their
|
|
27
|
+
* upstream's wording.
|
|
28
|
+
*/
|
|
29
|
+
const TRANSIENT_STREAM = /time[d ]?out|timed out|unavailable|overload|rate.?limit|too many requests|connection (?:reset|closed|refused)|ECONNRESET|temporar|\b5\d\d\b|internal.?server|bad gateway|service.?unavailable/i;
|
|
30
|
+
|
|
31
|
+
/** Structural, so retry.ts need not import an adapter to recognise its error. */
|
|
32
|
+
function isStreamFailure(error: unknown): error is Error & { code?: string } {
|
|
33
|
+
return error instanceof Error && error.name === "StreamError";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RetryPolicy {
|
|
37
|
+
/**
|
|
38
|
+
* Attempts after the first. Four means a request can take about 15s of
|
|
39
|
+
* backoff before failing, which covers a rate-limit window without making a
|
|
40
|
+
* broken request take a minute to report itself.
|
|
41
|
+
*/
|
|
42
|
+
maxRetries: number;
|
|
43
|
+
/**
|
|
44
|
+
* Longest server-requested delay worth honouring. Beyond this, fail. An
|
|
45
|
+
* agent that silently sleeps for twenty minutes cannot be told apart from
|
|
46
|
+
* one that has died, and is harder to diagnose than an error.
|
|
47
|
+
*/
|
|
48
|
+
maxRetryDelayMs: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_RETRY: RetryPolicy = { maxRetries: 4, maxRetryDelayMs: 60_000 };
|
|
52
|
+
|
|
53
|
+
export type RetryDecision =
|
|
54
|
+
| { retry: true; delayMs: number }
|
|
55
|
+
| { retry: false; reason: string };
|
|
56
|
+
|
|
57
|
+
/** What this module needs from an error: a status and headers, if it has them. */
|
|
58
|
+
interface HasResponse {
|
|
59
|
+
status?: unknown;
|
|
60
|
+
headers?: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function statusOf(error: unknown): number | undefined {
|
|
64
|
+
if (typeof error !== "object" || error === null) return undefined;
|
|
65
|
+
const status = (error as HasResponse).status;
|
|
66
|
+
return typeof status === "number" ? status : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function headersOf(error: unknown): Headers | undefined {
|
|
70
|
+
if (typeof error !== "object" || error === null) return undefined;
|
|
71
|
+
const headers = (error as HasResponse).headers;
|
|
72
|
+
return headers instanceof Headers ? headers : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether this status is about timing rather than about the request.
|
|
77
|
+
*
|
|
78
|
+
* 400 and 401 are the two worth naming: a malformed request and a bad key are
|
|
79
|
+
* deterministic, so retrying converts one clear failure into several slow ones
|
|
80
|
+
* and delays the only message that would have helped.
|
|
81
|
+
*/
|
|
82
|
+
function statusIsTransient(status: number): boolean {
|
|
83
|
+
return status === 408 || status === 409 || status === 429 || status >= 500;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function serverDelayMs(headers: Headers | undefined): number | undefined {
|
|
87
|
+
if (headers === undefined) return undefined;
|
|
88
|
+
|
|
89
|
+
// Milliseconds first: it is the more precise of the two, and a provider
|
|
90
|
+
// that sends both means the finer one.
|
|
91
|
+
const ms = headers.get("retry-after-ms");
|
|
92
|
+
if (ms !== null) {
|
|
93
|
+
const value = Number.parseFloat(ms);
|
|
94
|
+
if (!Number.isNaN(value)) return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const after = headers.get("retry-after");
|
|
98
|
+
if (after !== null) {
|
|
99
|
+
const seconds = Number.parseFloat(after);
|
|
100
|
+
if (!Number.isNaN(seconds)) return seconds * 1000;
|
|
101
|
+
// The header is also allowed to be an HTTP date.
|
|
102
|
+
const at = Date.parse(after);
|
|
103
|
+
if (!Number.isNaN(at)) return Math.max(0, at - Date.now());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether to try again, and how long to wait first.
|
|
111
|
+
*
|
|
112
|
+
* `jitter` is a value in [0, 1) supplied by the caller rather than drawn here,
|
|
113
|
+
* so the backoff arithmetic is deterministic under test. It only ever
|
|
114
|
+
* shortens the wait: lengthening it could push past the window the server
|
|
115
|
+
* implied, while shortening is what breaks up the thundering herd of clients
|
|
116
|
+
* that were all rate-limited at the same instant and would otherwise wake
|
|
117
|
+
* together.
|
|
118
|
+
*/
|
|
119
|
+
export function retryDecision(
|
|
120
|
+
error: unknown,
|
|
121
|
+
attempt: number,
|
|
122
|
+
policy: RetryPolicy,
|
|
123
|
+
jitter: number,
|
|
124
|
+
): RetryDecision {
|
|
125
|
+
if (attempt >= policy.maxRetries) {
|
|
126
|
+
return { retry: false, reason: `giving up after ${policy.maxRetries} retries` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const headers = headersOf(error);
|
|
130
|
+
const status = statusOf(error);
|
|
131
|
+
|
|
132
|
+
// The same prompt overflows the same way however often it is sent, even
|
|
133
|
+
// when a relay dresses the rejection up as a 5xx. The loop owns the remedy.
|
|
134
|
+
if (error instanceof Error && isContextOverflow(error.message, status)) {
|
|
135
|
+
return { retry: false, reason: "the prompt exceeds the model's context window" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A provider that knows whether its own error is worth retrying gets the
|
|
139
|
+
// last word, in both directions.
|
|
140
|
+
// A failure reported inside a 200 stream carries no status. Relays and
|
|
141
|
+
// Anthropic's own `overloaded_error` use it for exactly the conditions a
|
|
142
|
+
// 5xx would signal; anything else in a stream is a real rejection.
|
|
143
|
+
if (status === undefined && isStreamFailure(error)) {
|
|
144
|
+
if (!TRANSIENT_STREAM.test(`${error.code ?? ""} ${error.message}`)) {
|
|
145
|
+
return { retry: false, reason: "the stream reported a non-transient error" };
|
|
146
|
+
}
|
|
147
|
+
const base = Math.min(0.5 * 2 ** attempt, 8) * 1000;
|
|
148
|
+
return { retry: true, delayMs: base * (1 - jitter * 0.25) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const advice = headers?.get("x-should-retry");
|
|
152
|
+
let worthRetrying: boolean;
|
|
153
|
+
if (advice === "true") worthRetrying = true;
|
|
154
|
+
else if (advice === "false") worthRetrying = false;
|
|
155
|
+
else if (status !== undefined) worthRetrying = statusIsTransient(status);
|
|
156
|
+
else {
|
|
157
|
+
// No status at all. fetch reports a refused, reset or dropped connection
|
|
158
|
+
// as a TypeError, which is exactly the transient case. Anything else --
|
|
159
|
+
// a TypeError from our own code, a RangeError -- is a bug, and retrying a
|
|
160
|
+
// bug just runs it again.
|
|
161
|
+
worthRetrying = error instanceof TypeError;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!worthRetrying) {
|
|
165
|
+
return {
|
|
166
|
+
retry: false,
|
|
167
|
+
reason: status === undefined ? "not a retryable failure" : `HTTP ${status} is not retryable`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const requested = serverDelayMs(headers);
|
|
172
|
+
if (requested !== undefined) {
|
|
173
|
+
if (requested > policy.maxRetryDelayMs) {
|
|
174
|
+
return {
|
|
175
|
+
retry: false,
|
|
176
|
+
reason:
|
|
177
|
+
`server asked to wait ${Math.ceil(requested / 1000)}s, ` +
|
|
178
|
+
`longer than the ${Math.ceil(policy.maxRetryDelayMs / 1000)}s cap`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { retry: true, delayMs: requested };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 0.5s doubling to a ceiling of 8s, then flat.
|
|
185
|
+
const base = Math.min(0.5 * 2 ** attempt, 8) * 1000;
|
|
186
|
+
return { retry: true, delayMs: base * (1 - jitter * 0.25) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface RetryEffects {
|
|
190
|
+
sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
191
|
+
jitter(): number;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function abortReason(signal: AbortSignal): Error {
|
|
195
|
+
if (signal.reason instanceof Error) return signal.reason;
|
|
196
|
+
return new DOMException("The operation was aborted.", "AbortError");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A wait the caller can cancel.
|
|
201
|
+
*
|
|
202
|
+
* The SDK's own retry timers ignore AbortSignal, which is why the adapters
|
|
203
|
+
* retry with `maxRetries: 0` and sleep here. A sleep that runs to the end
|
|
204
|
+
* after Escape is the hang the user pressed it to get out of.
|
|
205
|
+
*/
|
|
206
|
+
function interruptibleSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
207
|
+
if (signal === undefined) {
|
|
208
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
209
|
+
}
|
|
210
|
+
return new Promise((resolve, reject) => {
|
|
211
|
+
if (signal.aborted) {
|
|
212
|
+
reject(abortReason(signal));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const timer = setTimeout(() => {
|
|
216
|
+
signal.removeEventListener("abort", onAbort);
|
|
217
|
+
resolve();
|
|
218
|
+
}, ms);
|
|
219
|
+
const onAbort = () => {
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
reject(abortReason(signal));
|
|
222
|
+
};
|
|
223
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const DEFAULT_RETRY_EFFECTS: RetryEffects = {
|
|
228
|
+
sleep: interruptibleSleep,
|
|
229
|
+
jitter: () => Math.random(),
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Resolves when `waiting` does, or rejects as soon as the signal fires.
|
|
234
|
+
*
|
|
235
|
+
* Injected sleeps are allowed to ignore the signal -- tests do, to prove
|
|
236
|
+
* this wrapper is what stops the wait. Swallowing the abandoned sleep
|
|
237
|
+
* avoids an unhandled rejection when it later settles.
|
|
238
|
+
*/
|
|
239
|
+
function waitOrAbort(waiting: Promise<void>, signal?: AbortSignal): Promise<void> {
|
|
240
|
+
if (signal === undefined) return waiting;
|
|
241
|
+
if (signal.aborted) {
|
|
242
|
+
void waiting.catch(() => {});
|
|
243
|
+
return Promise.reject(abortReason(signal));
|
|
244
|
+
}
|
|
245
|
+
return new Promise<void>((resolve, reject) => {
|
|
246
|
+
const onAbort = () => {
|
|
247
|
+
cleanup();
|
|
248
|
+
void waiting.catch(() => {});
|
|
249
|
+
reject(abortReason(signal));
|
|
250
|
+
};
|
|
251
|
+
const onSettled = (error?: unknown) => {
|
|
252
|
+
cleanup();
|
|
253
|
+
if (error === undefined) resolve();
|
|
254
|
+
else reject(error);
|
|
255
|
+
};
|
|
256
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
257
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
258
|
+
waiting.then(() => onSettled(), (error: unknown) => onSettled(error));
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Runs a request, retrying per the policy.
|
|
264
|
+
*
|
|
265
|
+
* `onRetry` exists so the caller can say something. A retry that produces no
|
|
266
|
+
* output is indistinguishable from a hang, which is the problem this was
|
|
267
|
+
* supposed to solve rather than one to introduce.
|
|
268
|
+
*
|
|
269
|
+
* `signal` is per-request, not per-policy: aborting one send must not
|
|
270
|
+
* poison the adapter. An already-aborted signal does not start an attempt,
|
|
271
|
+
* and an abort during backoff does not wait out the delay -- both would
|
|
272
|
+
* turn Escape into "wait, then maybe stop".
|
|
273
|
+
*/
|
|
274
|
+
export async function withRetry<T>(
|
|
275
|
+
request: () => Promise<T>,
|
|
276
|
+
policy: RetryPolicy = DEFAULT_RETRY,
|
|
277
|
+
fx: RetryEffects = DEFAULT_RETRY_EFFECTS,
|
|
278
|
+
onRetry?: (attempt: number, delayMs: number, error: unknown) => void,
|
|
279
|
+
signal?: AbortSignal,
|
|
280
|
+
): Promise<T> {
|
|
281
|
+
const effects = fx ?? DEFAULT_RETRY_EFFECTS;
|
|
282
|
+
for (let attempt = 0; ; attempt++) {
|
|
283
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
284
|
+
try {
|
|
285
|
+
return await request();
|
|
286
|
+
} catch (error) {
|
|
287
|
+
// Checked here rather than inside retryDecision: abort is a user
|
|
288
|
+
// decision, not a status code, and a fetch abort that surfaces as
|
|
289
|
+
// TypeError must not start another wait.
|
|
290
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
291
|
+
const decision = retryDecision(error, attempt, policy, effects.jitter());
|
|
292
|
+
if (!decision.retry) throw error;
|
|
293
|
+
onRetry?.(attempt + 1, decision.delayMs, error);
|
|
294
|
+
await waitOrAbort(effects.sleep(decision.delayMs, signal), signal);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|