@parcha/agentrun-kernel 0.0.0-stage → 0.1.0-beta.29

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 (82) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +6 -0
  4. package/README.md +52 -2
  5. package/dist/delegate.d.ts +87 -0
  6. package/dist/delegate.js +143 -0
  7. package/dist/errors.d.ts +36 -0
  8. package/dist/errors.js +37 -0
  9. package/dist/host/alive.d.ts +2 -0
  10. package/dist/host/alive.js +28 -0
  11. package/dist/host/box-slot.d.ts +26 -0
  12. package/dist/host/box-slot.js +112 -0
  13. package/dist/host/box.d.ts +104 -0
  14. package/dist/host/box.js +16 -0
  15. package/dist/host/egress-proxy.d.ts +45 -0
  16. package/dist/host/egress-proxy.js +416 -0
  17. package/dist/host/egress-table.d.ts +24 -0
  18. package/dist/host/egress-table.js +165 -0
  19. package/dist/host/index.d.ts +16 -0
  20. package/dist/host/index.js +12 -0
  21. package/dist/host/overhead.d.ts +53 -0
  22. package/dist/host/overhead.js +75 -0
  23. package/dist/host/parent-death.d.ts +15 -0
  24. package/dist/host/parent-death.js +42 -0
  25. package/dist/host/parent-watch.d.ts +1 -0
  26. package/dist/host/parent-watch.js +38 -0
  27. package/dist/host/pinned-binary.d.ts +6 -0
  28. package/dist/host/pinned-binary.js +48 -0
  29. package/dist/host/proxy.d.ts +179 -0
  30. package/dist/host/proxy.js +629 -0
  31. package/dist/host/serial.d.ts +3 -0
  32. package/dist/host/serial.js +10 -0
  33. package/dist/index.d.ts +11 -0
  34. package/dist/index.js +6 -0
  35. package/dist/kernel/blobs.d.ts +16 -0
  36. package/dist/kernel/blobs.js +59 -0
  37. package/dist/kernel/drive.d.ts +35 -0
  38. package/dist/kernel/drive.js +202 -0
  39. package/dist/kernel/errors.d.ts +27 -0
  40. package/dist/kernel/errors.js +42 -0
  41. package/dist/kernel/follow-up.d.ts +67 -0
  42. package/dist/kernel/follow-up.js +146 -0
  43. package/dist/kernel/halt.d.ts +11 -0
  44. package/dist/kernel/halt.js +17 -0
  45. package/dist/kernel/index.d.ts +8 -0
  46. package/dist/kernel/index.js +5 -0
  47. package/dist/kernel/types.d.ts +120 -0
  48. package/dist/kernel/types.js +1 -0
  49. package/dist/process/builtins.d.ts +142 -0
  50. package/dist/process/builtins.js +207 -0
  51. package/dist/process/inbox.d.ts +29 -0
  52. package/dist/process/inbox.js +52 -0
  53. package/dist/process/index.d.ts +9 -0
  54. package/dist/process/index.js +11 -0
  55. package/dist/process/journal.d.ts +111 -0
  56. package/dist/process/journal.js +232 -0
  57. package/dist/process/records.d.ts +143 -0
  58. package/dist/process/records.js +142 -0
  59. package/dist/process/recovery.d.ts +76 -0
  60. package/dist/process/recovery.js +139 -0
  61. package/dist/process/settle.d.ts +11 -0
  62. package/dist/process/settle.js +20 -0
  63. package/dist/process/tail.d.ts +20 -0
  64. package/dist/process/tail.js +53 -0
  65. package/dist/process/tools.d.ts +105 -0
  66. package/dist/process/tools.js +120 -0
  67. package/dist/process/zod.d.ts +32 -0
  68. package/dist/process/zod.js +109 -0
  69. package/dist/runner.d.ts +154 -0
  70. package/dist/runner.js +547 -0
  71. package/dist/schema-check-cases.d.ts +7 -0
  72. package/dist/schema-check-cases.js +73 -0
  73. package/dist/schema-check.d.ts +3 -0
  74. package/dist/schema-check.js +88 -0
  75. package/dist/skill-files.d.ts +23 -0
  76. package/dist/skill-files.js +136 -0
  77. package/dist/testing.d.ts +91 -0
  78. package/dist/testing.js +196 -0
  79. package/dist/types.d.ts +237 -0
  80. package/dist/types.js +10 -0
  81. package/package.json +74 -4
  82. package/vendor/durable-harness/UPSTREAM.json +67 -0
package/dist/runner.js ADDED
@@ -0,0 +1,547 @@
1
+ // A DSL node on any harness: createHarnessRunner turns an AgentAdapter into exactly the interpreter's `runNode`, so a
2
+ // DSL host and serve run a node on a harness adapter (Codex first) the way createPiRunner runs it on Pi. The
3
+ // node's record arrives through an injected `submit` tool whose input is the node's schema under `value`
4
+ // (submitParameters, the envelope both runners use); a review that does not accept it is the tool's result, in the
5
+ // same session. Every paid request passes the runner first: the turn cap, then the host's `beforeRequest` (its budget),
6
+ // each of which may refuse it. Under recovery a session is keyed by its step's sessionId: a delivered record is answered
7
+ // from the session's records, and a session cut off inside a turn is resumed by the adapter, spending nothing again.
8
+ import { randomUUID } from "node:crypto";
9
+ import { Compile } from "typebox/compile";
10
+ import { submitParameters } from "@parcha/agentrun-dsl";
11
+ import { HarnessRunError, SessionFenced } from "./errors.js";
12
+ /** The tool the runner injects; a host tool may not take its name. */
13
+ export const SUBMIT_TOOL = "submit";
14
+ /** Why a review rejected a candidate, as the model reads it: its message, or, for none (no message, or a blank one),
15
+ * that it gave no reason. Always a string, so a rejection never lets the candidate through (Pi's runner reads a review
16
+ * the same way). A long one reaches the model through the host's one bound on a result (HOST_RESULT_MAX_CHARS). */
17
+ const reviewRejection = (message) => {
18
+ const said = message === undefined || message === null ? "" : String(message);
19
+ return said.trim() ? said : "the review rejected it without saying why";
20
+ };
21
+ /** A held figure as a host gives it or a record keeps it: a finite amount, 0 or more, else none. */
22
+ const heldFigure = (value) => (typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined);
23
+ /** `total` with `cost` added: unknown once either is. */
24
+ const spent = (total, cost) => (total === null || cost === undefined ? null : total + cost);
25
+ /** The records the runner keeps beside the adapter's own, in each session. */
26
+ /** `request` is written before a paid request leaves, `turn` when it has answered with its usage: a request a crash cut off
27
+ * keeps its `request` and has no `turn`. */
28
+ export const RUNNER_RECORDS = { request: "runner.request", response: "runner.response", turn: "runner.turn", submit: "runner.submit", accepted: "runner.accepted" };
29
+ const positive = (value, name) => {
30
+ if (!Number.isSafeInteger(value) || value < 1)
31
+ throw new Error(`${name} must be a positive integer`);
32
+ return value;
33
+ };
34
+ /** Turns in a row that may end with no paid request and no accepted submission before a node ends `no_progress`. The
35
+ * turn limit counts paid requests only, so without this bound a harness whose turns never reach its model (a cached or
36
+ * refused answer, a misconfigured model) would be sent turns forever, on microtasks no timer can interrupt. */
37
+ export const NO_PROGRESS_TURNS = 3;
38
+ /** The runner's booking of paid model requests, for a host that drives an adapter session itself (a chat run): the same
39
+ * records (`runner.request` before a request leaves, `runner.turn` with its cost when it answers), the same turn limit
40
+ * and `beforeRequest`, the same events. `createHarnessRunner` is built on it. */
41
+ export async function modelPortFor(writer, options) {
42
+ return bookingFor(writer, { ...options, records: options.records ?? (await writer.read()) });
43
+ }
44
+ function bookingFor(writer, options) {
45
+ const node = { key: options.key, label: options.label ?? options.key, ...(options.executionPath === undefined ? {} : { executionPath: options.executionPath }) };
46
+ const limit = options.maxTurns ?? null;
47
+ const append = options.append ?? ((records) => writer.append(records));
48
+ const emit = (event) => options.onEvent?.({ ...event, ...node });
49
+ // A turn is a paid request that left: counted from the records written before each one left, so a request a crash cut
50
+ // off still counts against the turn cap. What it cost is unknown: the session's spend counts it at the figure its
51
+ // host held for it, on its record, and is unknown from then on when there is none.
52
+ const requests = options.records.filter((r) => r.kind === RUNNER_RECORDS.request).map((r) => (r.data ?? {}));
53
+ const inherited = requests.map((data, i) => data.turn ?? i + 1);
54
+ let turns = inherited.length;
55
+ /** What the host held for each request, by turn: what it counts at when its own cost never comes in. */
56
+ const held = new Map();
57
+ /** What the host counts each request of this process at if its client stops it part way (Admission.stoppedUsd). */
58
+ const stoppedAt = new Map();
59
+ requests.forEach((data, i) => {
60
+ const figure = heldFigure(data.heldUsd);
61
+ if (figure !== undefined)
62
+ held.set(inherited[i], figure);
63
+ });
64
+ // Which of them were answered: each runner.turn names its own turn (one written before turns were named answers the
65
+ // earliest still open, as answers then came in order).
66
+ const answered = new Set();
67
+ let spentUsd = 0;
68
+ for (const r of options.records) {
69
+ if (r.kind !== RUNNER_RECORDS.turn)
70
+ continue;
71
+ const data = r.data;
72
+ const turn = data.turn ?? inherited.find((t) => !answered.has(t)) ?? 0;
73
+ answered.add(turn);
74
+ // A cost that never came in: at the figure its answer was booked at (a stopped request's), else what was held for it.
75
+ spentUsd = spent(spentUsd, data.usd === null ? (heldFigure(data.heldUsd) ?? held.get(turn)) : (data.usd ?? 0));
76
+ }
77
+ for (const turn of inherited)
78
+ if (!answered.has(turn))
79
+ spentUsd = spent(spentUsd, held.get(turn));
80
+ // The ids each request's response gave it, kept as its headers arrived: a request cut off after them reached the provider.
81
+ const responses = new Map();
82
+ for (const r of options.records) {
83
+ if (r.kind !== RUNNER_RECORDS.response)
84
+ continue;
85
+ const data = r.data;
86
+ if (typeof data.turn === "number" && data.providerIds)
87
+ responses.set(data.turn, data.providerIds);
88
+ }
89
+ // This process's requests in flight, oldest first: the turn each was admitted as, and its model.
90
+ const open = [];
91
+ // Response ids whose own write was refused, marked `late`: the booking's next write of its own (the next runner.request,
92
+ // or a runner.turn) carries them first, so a request this process cuts by dying after that still keeps its ids. One a
93
+ // runner.turn writes with the ids is dropped, never written twice. A process that dies before writing for its booking
94
+ // again loses them: the request is then booked once, cut, at an unknown cost, with no ids.
95
+ let carry = [];
96
+ const write = async (records, covers = () => false) => {
97
+ const taken = carry;
98
+ carry = [];
99
+ try {
100
+ await append([...taken.filter((r) => !covers(r)), ...records]);
101
+ }
102
+ catch (error) {
103
+ carry = [...taken, ...carry];
104
+ throw error;
105
+ }
106
+ };
107
+ // Admissions run one at a time (the turn limit, the host's check and the durable write), so two requests asked for at
108
+ // once can't both pass a limit only one fits; the requests themselves still run side by side once admitted.
109
+ let admitting = Promise.resolve();
110
+ // A paid request whose cost couldn't be recorded stops the booking itself, whether or not the host gave it a stop.
111
+ let failed;
112
+ const end = (reason, message) => options.stop ? options.stop(reason, message) : new HarnessRunError(reason, turns, 0, { maxTurns: limit, ...(message === undefined ? {} : { message }) });
113
+ const admit = async ({ model, inputTokensMax, outputTokensAsked, outputTokensLeast }) => {
114
+ const stopped = options.stopped?.() ?? failed;
115
+ if (stopped)
116
+ throw stopped;
117
+ const pausing = options.draining?.();
118
+ if (pausing)
119
+ throw pausing;
120
+ if (limit !== null && turns >= limit)
121
+ throw end("turn_limit");
122
+ const verdict = await options.beforeRequest?.({ ...node, model, turn: turns + 1, maxTurns: limit, ...(options.budgetUsd === undefined ? {} : { budgetUsd: options.budgetUsd }), spentUsd, inFlight: open.map((f) => ({ ...f })), inputTokensMax,
123
+ ...(outputTokensAsked === undefined ? {} : { outputTokensAsked }), ...(outputTokensLeast === undefined ? {} : { outputTokensLeast }) });
124
+ if (verdict && "refuse" in verdict)
125
+ throw end("refused", verdict.refuse);
126
+ const hold = verdict && "holdUsd" in verdict ? heldFigure(verdict.holdUsd) : undefined;
127
+ // The output limit the host admits it with goes into its body. A limit that is no whole number of tokens above 0 (a
128
+ // fraction, 0, a negative, NaN) can't be sent, so the request is refused before anything is written: dropping it would
129
+ // send the request with no limit at all. Only a verdict with no limit sends the request with none.
130
+ const limited = verdict && "outputTokensMax" in verdict ? verdict.outputTokensMax : undefined;
131
+ if (limited !== undefined && !(Number.isSafeInteger(limited) && limited > 0))
132
+ throw end("refused", `the request was not sent: its output limit (${String(limited)}) is not a whole number of tokens above 0`);
133
+ // A stop or a pause that came while the host was checking: nothing was written for this request, so it is refused, not
134
+ // sent. After the write below it is committed (refusing it then would leave a recorded request that was never sent).
135
+ const late = options.stopped?.() ?? failed ?? options.draining?.();
136
+ if (late)
137
+ throw late;
138
+ // Durable before the request leaves: admitted, then sent. It counts as a turn once its record is written (admissions
139
+ // run one at a time), so a write that fails refuses the request and costs no turn.
140
+ const turn = turns + 1;
141
+ await write([{ kind: RUNNER_RECORDS.request, data: { turn, model, ...(hold === undefined ? {} : { heldUsd: hold }) } }]);
142
+ turns = turn;
143
+ if (hold !== undefined)
144
+ held.set(turn, hold);
145
+ const stoppedUsd = verdict && "stoppedUsd" in verdict && typeof verdict.stoppedUsd === "function" ? verdict.stoppedUsd : undefined;
146
+ if (stoppedUsd)
147
+ stoppedAt.set(turn, stoppedUsd);
148
+ // A stop that came while the record was written: the request has not left. It is booked withdrawn, at $0, so no
149
+ // later process books it as cut at an unknown price; if that write fails, it is booked cut, which is never less.
150
+ // Its record is written, so it costs a turn of the cap, never money.
151
+ const stoppedNow = options.stopped?.();
152
+ if (stoppedNow) {
153
+ answered.add(turn);
154
+ // Through the booking's own write: a response's ids still waiting to be carried go with the withdrawal.
155
+ await write([{ kind: RUNNER_RECORDS.turn, data: { turn, usd: 0, unsent: true } }]).catch(() => undefined);
156
+ throw stoppedNow;
157
+ }
158
+ open.push({ turn, model, inputTokensMax, ...(limited === undefined ? {} : { outputTokensMax: limited }) });
159
+ emit({ type: "request_start", turn, model });
160
+ return { turn, ...(limited === undefined ? {} : { outputTokensMax: limited }) };
161
+ };
162
+ const models = {
163
+ beforeRequest: (request) => {
164
+ const admitted = admitting.then(() => admit(request));
165
+ admitting = admitted.catch(() => { });
166
+ return admitted;
167
+ },
168
+ responded: async (ids, ticket) => {
169
+ const turn = ticket?.turn ?? open[0]?.turn ?? turns;
170
+ try {
171
+ await append([{ kind: RUNNER_RECORDS.response, data: { turn, providerIds: ids } }]);
172
+ }
173
+ catch (error) {
174
+ // Refused (a commit that timed out, say): the proxy goes on with the answer, and the ids wait for the next write.
175
+ carry.push({ kind: RUNNER_RECORDS.response, data: { turn, providerIds: ids, late: true } });
176
+ throw error;
177
+ }
178
+ },
179
+ afterRequest: async (usage, ticket) => {
180
+ // The turn this request was admitted as (its ticket; without one, the earliest still in flight).
181
+ const turn = ticket?.turn ?? open[0]?.turn ?? turns;
182
+ const at = open.findIndex((f) => f.turn === turn);
183
+ if (at >= 0)
184
+ open.splice(at, 1);
185
+ // A cost that never came in (a response cut short): at what the host held for the request, else unknown. One its
186
+ // client stopped part way at what the host counts it at by what streamed (one token a byte), never more than that.
187
+ const hold = held.get(turn);
188
+ const stoppedUsd = stoppedAt.get(turn);
189
+ stoppedAt.delete(turn);
190
+ const stopped = usage.usd === null && hold !== undefined && stoppedUsd && typeof usage.stoppedAfterBytes === "number" && usage.stoppedAfterBytes >= 0 ? heldFigure(stoppedUsd(usage.stoppedAfterBytes)) : undefined;
191
+ const figure = stopped === undefined ? hold : Math.min(hold, stopped);
192
+ spentUsd = spent(spentUsd, usage.usd === null ? figure : usage.usd);
193
+ try {
194
+ // A runner.turn with the ids holds what a carried response record of this turn would: that one is dropped. A
195
+ // stopped one keeps the figure it counts at (`heldUsd`), so a later process counts it the same.
196
+ const covers = (r) => usage.providerIds !== undefined && r.data.turn === turn;
197
+ await write([{ kind: RUNNER_RECORDS.turn, data: { turn, usd: usage.usd, ...tokensOf(usage), ...(usage.estimated ? { estimated: true } : {}), ...(usage.tableUsd === undefined ? {} : { tableUsd: usage.tableUsd }), ...(usage.providerIds ? { providerIds: usage.providerIds } : {}), ...(stopped === undefined ? {} : { heldUsd: figure, stopped: true }) } }], covers);
198
+ }
199
+ catch (error) {
200
+ // A paid request whose cost can't be kept: the attempt stops, saying so, rather than spend on uncounted.
201
+ if (!options.stopped?.())
202
+ failed ??= end("model_error", `harness node stopped: the cost of a paid request could not be recorded: ${String(error?.message ?? error).slice(0, 200)}`);
203
+ throw error;
204
+ }
205
+ answered.add(turn);
206
+ emit({ type: "request_end", turn, usage });
207
+ },
208
+ };
209
+ // One booking of the cut requests at a time: a call made while one is writing waits for it, never books them again.
210
+ let cutting;
211
+ const bookCut = async () => {
212
+ // A paid request a crash cut off kept its runner.request and has no runner.turn: the provider may have billed it.
213
+ // Each is booked once, as cut, at an unknown price, and the host is told as for any answered request, so its ledger
214
+ // shows the possible spend instead of nothing. Booked, it is answered: a later attempt never books it again.
215
+ // Only requests an earlier process admitted: one this process has in flight is not cut.
216
+ const numbers = inherited.filter((t) => !answered.has(t));
217
+ if (numbers.length === 0)
218
+ return;
219
+ // A cut request whose response had begun carries the ids it gave: the provider saw it, under them. One with none may
220
+ // still have reached the provider before any answer; both are booked at an unknown price.
221
+ const cutOf = (turn) => {
222
+ const providerIds = responses.get(turn);
223
+ return providerIds ? { usd: null, cut: true, turn, providerIds } : { usd: null, cut: true, turn };
224
+ };
225
+ await append(numbers.map((turn) => ({ kind: RUNNER_RECORDS.turn, data: cutOf(turn) })));
226
+ for (const turn of numbers)
227
+ answered.add(turn);
228
+ // The spend already counts each of them, at its held figure or as unknown: booking them adds nothing.
229
+ for (const turn of numbers) {
230
+ const providerIds = responses.get(turn);
231
+ emit({ type: "request_end", turn, usage: { input: 0, output: 0, cacheRead: 0, usd: null, estimated: true, cut: true, ...(providerIds ? { providerIds } : {}) } });
232
+ }
233
+ };
234
+ return {
235
+ models,
236
+ turns: () => turns,
237
+ spentUsd: () => spentUsd,
238
+ bookCut() {
239
+ cutting ??= bookCut().catch((error) => {
240
+ cutting = undefined;
241
+ throw error;
242
+ });
243
+ return cutting;
244
+ },
245
+ };
246
+ }
247
+ /** A booking's tokens as its runner.turn keeps them (`usage`, in pi's shape: `input` excludes cached tokens), or nothing
248
+ * when it reports none: a request whose usage was not read is booked with zeros, which are no count. */
249
+ function tokensOf(usage) {
250
+ const tokens = { input: usage.input, output: usage.output, cacheRead: usage.cacheRead ?? 0, cacheWrite: usage.cacheWrite ?? 0 };
251
+ return tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite > 0 ? { usage: tokens } : {};
252
+ }
253
+ const NUDGE = "You finished without an accepted submission. Call submit with the complete result; plain text does not deliver it.";
254
+ export function createHarnessRunner(adapter, options) {
255
+ const maxTurns = options.maxTurns === null ? null : positive(options.maxTurns ?? 8, "maxTurns");
256
+ const maxSends = positive(options.maxSends ?? 100, "maxSends");
257
+ const maxSubmissions = options.maxSubmissions === null ? null : positive(options.maxSubmissions ?? 5, "maxSubmissions");
258
+ const timeoutMs = options.timeoutMs === null ? null : positive(options.timeoutMs ?? 120_000, "timeoutMs");
259
+ const registered = new Map();
260
+ for (const tool of options.tools ?? []) {
261
+ if (tool.name === SUBMIT_TOOL || registered.has(tool.name))
262
+ throw new Error(`Reserved or duplicate harness tool: ${tool.name}`);
263
+ registered.set(tool.name, tool);
264
+ }
265
+ const store = options.store;
266
+ /** The nodes running now, each by how it is paused; and whether the runner was stopped. */
267
+ const running = new Map();
268
+ let halted = false;
269
+ const runNode = async (request, pause) => {
270
+ // Without a step there is no durable identity: a fresh session of the runner's own, never one derived from the
271
+ // label, which map items and loops repeat.
272
+ const key = request.step ? `step:${request.step.sessionId}` : `call:${randomUUID()}`;
273
+ const node = { key, label: request.label, ...(request.executionPath === undefined ? {} : { executionPath: request.executionPath }) };
274
+ const emit = (event) => options.onEvent?.({ ...event, ...node });
275
+ const refuse = (message, turns = 0, submissions = 0) => new HarnessRunError("refused", turns, submissions, { message });
276
+ const signals = [options.signal, request.signal].filter((s) => Boolean(s));
277
+ if (halted || signals.some((s) => s.aborted))
278
+ throw new HarnessRunError("aborted", 0, 0);
279
+ if (request.maxTurns !== undefined) {
280
+ positive(request.maxTurns, "maxTurns");
281
+ if (maxTurns !== null && request.maxTurns > maxTurns)
282
+ throw refuse(`Node "${request.label}" asks max_turns ${request.maxTurns}, above this host's cap of ${maxTurns} turns`);
283
+ }
284
+ const limit = request.maxTurns ?? maxTurns;
285
+ // Absent: every registered tool. An unknown name is refused, never dropped.
286
+ const requested = request.tools ?? [...registered.keys()];
287
+ for (const name of requested)
288
+ if (!registered.has(name))
289
+ throw refuse(`Node "${request.label}" names the tool ${name}, which this host does not give ${adapter.id} nodes`);
290
+ // This attempt takes the session before it reads it: from here a process that held it earlier (one whose lease
291
+ // lapsed while it was still alive) can write nothing more into it, a runner.accepted included.
292
+ const writer = await store.open(key);
293
+ // A session that already delivered: its record, and nothing runs.
294
+ const earlier = request.step ? await writer.read() : [];
295
+ const delivered = earlier.findLast((r) => r.kind === RUNNER_RECORDS.accepted);
296
+ if (delivered)
297
+ return structuredClone(delivered.data.value);
298
+ const validator = Compile(request.schema);
299
+ const parameters = submitParameters(request.schema);
300
+ const envelope = Compile(parameters);
301
+ let submissions = earlier.filter((r) => r.kind === RUNNER_RECORDS.submit).length;
302
+ // The session's paid requests, counted and booked by its model port (modelPortFor), made once `append` exists below.
303
+ let booking;
304
+ const turnsSoFar = () => booking?.turns() ?? 0;
305
+ let accepted = false;
306
+ let submission;
307
+ let lastTool;
308
+ let stopped;
309
+ // A pause in progress: nothing new starts, and the node stops once its session has drained.
310
+ let draining = false;
311
+ // Calls the session announced (tool_start) before the pause: they are in flight, not new, and run on through a drain.
312
+ // A stop with no drain (drainMs 0) lets nothing run on, announced or not.
313
+ const announced = new Set();
314
+ let immediate = false;
315
+ const failure = (reason, message) => new HarnessRunError(reason, turnsSoFar(), submissions, { maxTurns: limit, ...(lastTool === undefined ? {} : { lastTool }), ...(message === undefined ? {} : { message }) });
316
+ const controller = new AbortController();
317
+ let rejectStopped;
318
+ const interrupted = new Promise((_, reject) => { rejectStopped = reject; });
319
+ void interrupted.catch(() => { });
320
+ const bounded = (work) => Promise.race([work, interrupted]);
321
+ const stop = (error) => {
322
+ stopped ??= error;
323
+ rejectStopped(stopped);
324
+ controller.abort(stopped);
325
+ };
326
+ const onAbort = () => stop(failure("aborted"));
327
+ const append = async (records) => {
328
+ try {
329
+ await writer.append(records);
330
+ }
331
+ catch (error) {
332
+ // A newer process holds the session: this attempt stops, and what it was writing never counts.
333
+ if (error instanceof SessionFenced) {
334
+ stop(failure("fenced"));
335
+ throw stopped;
336
+ }
337
+ throw error;
338
+ }
339
+ };
340
+ booking = bookingFor(writer, {
341
+ ...node, maxTurns: limit, records: earlier, append,
342
+ ...(request.budgetUsd === undefined ? {} : { budgetUsd: request.budgetUsd }),
343
+ ...(options.beforeRequest ? { beforeRequest: options.beforeRequest } : {}),
344
+ onEvent: (event) => emit(event),
345
+ stopped: () => stopped,
346
+ draining: () => (draining ? failure("aborted", "harness node pausing: no new paid request") : undefined),
347
+ stop: (reason, message) => { stop(failure(reason, message)); return stopped; },
348
+ });
349
+ const submit = {
350
+ name: SUBMIT_TOOL,
351
+ description: "Deliver a result matching the required JSON Schema. Rejection feedback must be corrected in this session.",
352
+ inputSchema: parameters,
353
+ replay: "safe",
354
+ async call(args) {
355
+ if (accepted)
356
+ return { text: "Already accepted; end your turn." };
357
+ // A stop with no drain takes no answer, though one inside a drain is kept (it is the end of a paid response).
358
+ if (stopped || (draining && immediate))
359
+ return { text: "This session has stopped.", isError: true };
360
+ submissions += 1;
361
+ await append([{ kind: RUNNER_RECORDS.submit, data: { n: submissions } }]);
362
+ const candidate = args?.value;
363
+ let message;
364
+ if (!envelope.Check(args))
365
+ message = JSON.stringify([...envelope.Errors(args)]);
366
+ else if (!validator.Check(candidate))
367
+ message = JSON.stringify([...validator.Errors(candidate)]);
368
+ else if (request.review) {
369
+ const verdict = await request.review(candidate);
370
+ if (!verdict.accepted)
371
+ message = reviewRejection(verdict.message);
372
+ else if (!validator.Check(candidate))
373
+ message = "The reviewed candidate no longer satisfies its output schema";
374
+ }
375
+ if (stopped)
376
+ return { text: "This session has stopped.", isError: true };
377
+ if (message !== undefined) {
378
+ if (maxSubmissions !== null && submissions >= maxSubmissions)
379
+ stop(failure("submission_limit"));
380
+ return { text: `Rejected: ${message}. Correct the result and call submit again.`, isError: true };
381
+ }
382
+ // Durable before it is acknowledged: a process that dies after this answers the step from the record.
383
+ await append([{ kind: RUNNER_RECORDS.accepted, data: { value: structuredClone(candidate) } }]);
384
+ submission = structuredClone(candidate);
385
+ accepted = true;
386
+ return { text: "Accepted. End your turn." };
387
+ },
388
+ };
389
+ const guard = (tool) => ({
390
+ ...tool,
391
+ call: (args, context) => {
392
+ if (accepted || stopped)
393
+ return Promise.reject(new Error("the session has stopped; tool execution refused"));
394
+ // A stop with no drain runs nothing more; a drain lets a call announced before it run on.
395
+ if (draining && immediate)
396
+ return Promise.reject(new Error("the session has stopped; this call was not run"));
397
+ if (draining && !announced.has(context.callId))
398
+ return Promise.reject(new Error("the session is pausing; this call was not run"));
399
+ return tool.call(args, options.sessionDir ? { ...context, sessionDir: options.sessionDir } : context);
400
+ },
401
+ });
402
+ const spec = {
403
+ ...node,
404
+ system: [...request.system, 'Only a successful submit tool call delivers the result. Call submit with {"value": YOUR_RESULT}. The schema below describes value, not the envelope.', `Required JSON Schema: ${JSON.stringify(request.schema)}`],
405
+ tools: [...requested.map((name) => guard(registered.get(name))), submit],
406
+ context: request.context === "fork" ? "fork" : "fresh",
407
+ ...(request.tier === undefined ? {} : { tier: request.tier }),
408
+ ...(request.effort === undefined ? {} : { effort: request.effort }),
409
+ ...(request.thinking === undefined ? {} : { thinking: request.thinking }),
410
+ };
411
+ // Refused at create time, before anything is spent: a fork the adapter cannot make, or anything the adapter refuses.
412
+ const problems = [
413
+ ...(spec.context === "fork" && !adapter.capabilities().fork ? [`Node "${request.label}" asks for context: "fork", but ${adapter.id} has no caller transcript to fork. Declare context: "fresh".`] : []),
414
+ ...adapter.check(spec),
415
+ ];
416
+ if (problems.length)
417
+ throw refuse(problems.join("; "));
418
+ const models = booking.models;
419
+ let session;
420
+ let opening;
421
+ // Armed here, with nothing between them and the try whose finally releases them: a refusal above never leaves a timer.
422
+ signals.forEach((s) => s.addEventListener("abort", onAbort, { once: true }));
423
+ // A stop that landed while the store was read: a listener added to an aborted signal never fires.
424
+ if (signals.some((s) => s.aborted))
425
+ onAbort();
426
+ const timer = timeoutMs === null ? undefined : setTimeout(() => stop(failure("timeout")), timeoutMs);
427
+ // A pause: nothing new starts, the session drains its calls in flight (an adapter that can't drain stops at once),
428
+ // then the node stops. One that arrived before this node could be paused stops it here.
429
+ pause(async (drainMs) => {
430
+ immediate = drainMs === 0;
431
+ draining = true;
432
+ if (session)
433
+ await session.interrupt({ drainMs }).catch(() => { });
434
+ stop(failure("aborted"));
435
+ });
436
+ if (halted) {
437
+ draining = true;
438
+ stop(failure("aborted"));
439
+ }
440
+ try {
441
+ if (stopped)
442
+ throw stopped;
443
+ // A paid request a crash cut off is booked once, as cut, at an unknown price, before this attempt sends anything.
444
+ await bounded(booking.bookCut());
445
+ if (stopped)
446
+ throw stopped;
447
+ session = await bounded(opening = adapter.open(spec, {
448
+ writer,
449
+ models,
450
+ signal: controller.signal,
451
+ ...(options.sessionDir ? { dir: options.sessionDir(key) } : {}),
452
+ onEvent: (event) => {
453
+ if (event.type === "tool_start") {
454
+ lastTool = event.name.slice(0, 64);
455
+ if (!draining)
456
+ announced.add(event.callId);
457
+ }
458
+ emit(event);
459
+ },
460
+ }));
461
+ const turnOf = async () => {
462
+ const end = await bounded(session.turnEnd());
463
+ if (end.status === "failed" && !accepted)
464
+ stop(failure("model_error", end.error));
465
+ };
466
+ // Sends have their own count, checked against the same limit (or maxSends when it is null), never added to the paid
467
+ // requests: one send may make several. They need no record: a crash restarts them, and the recorded requests still
468
+ // bound the money. Turns that make no paid request also end the node `no_progress` after a few in a row.
469
+ const sendLimit = limit ?? maxSends;
470
+ let sends = 0;
471
+ let idle = 0;
472
+ const progressed = (before) => {
473
+ idle = accepted || turnsSoFar() > before ? 0 : idle + 1;
474
+ if (idle >= NO_PROGRESS_TURNS && !accepted)
475
+ stop(failure("no_progress", `harness node stopped: no_progress, ${idle} turns in a row made no paid request and delivered nothing`));
476
+ };
477
+ if (session.state === "open_turn") {
478
+ const before = turnsSoFar();
479
+ sends += 1;
480
+ await bounded(session.recover());
481
+ await turnOf();
482
+ progressed(before);
483
+ }
484
+ let prompt = session.state === "new" ? request.user : NUDGE;
485
+ while (!accepted && !stopped && !draining && (limit === null || turnsSoFar() < limit) && sends < sendLimit) {
486
+ const before = turnsSoFar();
487
+ sends += 1;
488
+ await bounded(session.send(prompt));
489
+ await turnOf();
490
+ progressed(before);
491
+ prompt = NUDGE;
492
+ }
493
+ if (accepted)
494
+ return submission;
495
+ if (draining)
496
+ stop(failure("aborted"));
497
+ if (stopped)
498
+ throw stopped;
499
+ if (limit !== null && turnsSoFar() >= limit)
500
+ throw failure("turn_limit");
501
+ // The sends ran out first: say so, since the turns reported are below the limit.
502
+ throw failure("turn_limit", `harness node stopped: turn_limit, ${sends} turns sent (the cap on sends) made ${turnsSoFar()} paid requests and ${submissions} submissions`);
503
+ }
504
+ catch (error) {
505
+ if (accepted)
506
+ return submission;
507
+ if (stopped)
508
+ throw stopped;
509
+ throw error;
510
+ }
511
+ finally {
512
+ clearTimeout(timer);
513
+ signals.forEach((s) => s.removeEventListener("abort", onAbort));
514
+ if (session) {
515
+ if (!accepted && stopped)
516
+ await session.interrupt().catch(() => { });
517
+ await session.close().catch(() => { });
518
+ }
519
+ else if (opening) {
520
+ // The node stopped while its session was opening: a session that opens later is stopped and let go, so no harness
521
+ // process outlives the node.
522
+ void opening.then(async (late) => {
523
+ await late.interrupt().catch(() => { });
524
+ await late.close().catch(() => { });
525
+ }, () => { });
526
+ }
527
+ }
528
+ };
529
+ const run = (request) => {
530
+ let drain = async (_drainMs) => { };
531
+ const node = runNode(request, (pause) => { drain = pause; });
532
+ running.set(node, (drainMs) => drain(drainMs));
533
+ void node.then(() => running.delete(node), () => running.delete(node));
534
+ return node;
535
+ };
536
+ return Object.assign(run, {
537
+ async stop(stopOptions = {}) {
538
+ const drainMs = stopOptions.drainMs ?? 0;
539
+ if (!Number.isFinite(drainMs) || drainMs < 0)
540
+ throw new Error("drainMs must be a non-negative number of milliseconds");
541
+ halted = true;
542
+ const nodes = [...running];
543
+ await Promise.all(nodes.map(([, drain]) => drain(drainMs)));
544
+ await Promise.allSettled(nodes.map(([node]) => node));
545
+ },
546
+ });
547
+ }
@@ -0,0 +1,7 @@
1
+ /** One case: the input schema, and where it is refused (JSON pointers), or `[]` when it may be used. */
2
+ export interface SchemaCheckCase {
3
+ name: string;
4
+ schema: unknown;
5
+ refusedAt: string[];
6
+ }
7
+ export declare const SCHEMA_CHECK_CASES: readonly SchemaCheckCase[];
@@ -0,0 +1,73 @@
1
+ // The cases `toolSchemaProblems` decides, for every adapter's own check() tests: each harness runs the same table, so
2
+ // their columns agree by test. Written for AgentRun.
3
+ const P = { a: { type: "string" }, b: { type: "string" } };
4
+ /** A schema `depth` objects deep, each one property down. */
5
+ const nested = (depth) => {
6
+ let schema = { type: "string" };
7
+ for (let i = 0; i < depth; i += 1)
8
+ schema = { type: "object", properties: { a: schema } };
9
+ return schema;
10
+ };
11
+ export const SCHEMA_CHECK_CASES = Object.freeze([
12
+ // The root: an object of arguments, nothing else.
13
+ { name: "a root with no type", schema: { properties: P, required: ["a"] }, refusedAt: ["/"] },
14
+ { name: "a root anyOf of objects", schema: { anyOf: [{ type: "object", properties: P }, { type: "object" }] }, refusedAt: ["/"] },
15
+ { name: "a root of type array", schema: { type: "array", items: { type: "string" } }, refusedAt: ["/"] },
16
+ { name: 'a root of type ["object", "null"]', schema: { type: ["object", "null"], properties: P }, refusedAt: ["/"] },
17
+ { name: "a root that is only a $ref", schema: { $ref: "#/$defs/X", $defs: { X: { type: "object", properties: P } } }, refusedAt: ["/"] },
18
+ { name: "a root that is not a schema object", schema: "object", refusedAt: ["/"] },
19
+ { name: "no schema at all", schema: undefined, refusedAt: ["/"] },
20
+ // JSON Schema 2020-12.
21
+ { name: 'a type of "strng"', schema: { type: "object", properties: { a: { type: "strng" } } }, refusedAt: ["/properties/a/type"] },
22
+ { name: "a minimum given as a string", schema: { type: "object", properties: { n: { type: "number", minimum: "1" } } }, refusedAt: ["/properties/n/minimum"] },
23
+ { name: "required not an array", schema: { type: "object", properties: P, required: "a" }, refusedAt: ["/required"] },
24
+ { name: "tuple items (an array of schemas)", schema: { type: "object", properties: { t: { type: "array", items: [{ type: "string" }, { type: "number" }] } } }, refusedAt: ["/properties/t/items"] },
25
+ // $schema: a dialect Anthropic's API takes (measured: 2020-12, 2019-09, draft-07, -06, -04), or refused there.
26
+ { name: "a $schema of draft-07", schema: { $schema: "http://json-schema.org/draft-07/schema#", type: "object", properties: P }, refusedAt: [] },
27
+ { name: "a $schema of 2020-12", schema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: P }, refusedAt: [] },
28
+ { name: "a $schema that is not a string", schema: { $schema: 42, type: "object", properties: P }, refusedAt: ["/$schema"] },
29
+ { name: "a $schema naming no dialect", schema: { $schema: "https://example.com/unknown", type: "object", properties: P }, refusedAt: ["/$schema"] },
30
+ // A check never throws: a schema so deep the metaschema check overflows is a problem at "/", and so is one deeper still.
31
+ { name: "a schema 2,000 objects deep (the metaschema check overflows)", schema: nested(2_000), refusedAt: ["/"] },
32
+ { name: "a schema 5,000 objects deep", schema: nested(5_000), refusedAt: ["/"] },
33
+ { name: "a schema 100 objects deep", schema: nested(100), refusedAt: [] },
34
+ // $ref: local followed, remote refused.
35
+ { name: "a remote $ref by URL", schema: { type: "object", properties: { r: { $ref: "https://example.com/schema.json" } } }, refusedAt: ["/properties/r/$ref"] },
36
+ { name: "a remote $ref by file", schema: { type: "object", properties: { r: { $ref: "other.json#/x" } } }, refusedAt: ["/properties/r/$ref"] },
37
+ { name: "a local $ref into $defs", schema: { type: "object", properties: { r: { $ref: "#/$defs/X" } }, $defs: { X: { type: "object", properties: P } } }, refusedAt: [] },
38
+ {
39
+ name: "a remote $ref in a schema position deep inside (anyOf in $defs)",
40
+ schema: { type: "object", properties: { r: { $ref: "#/$defs/X" } }, $defs: { X: { anyOf: [{ $ref: "https://example.com/a.json" }, { type: "null" }] } } },
41
+ refusedAt: ["/$defs/X/anyOf/0/$ref"],
42
+ },
43
+ {
44
+ name: "a remote $ref in a contentSchema (a schema position)",
45
+ schema: { type: "object", properties: { payload: { type: "string", contentMediaType: "application/json", contentSchema: { $ref: "https://example.com/payload.json" } } } },
46
+ refusedAt: ["/properties/payload/contentSchema/$ref"],
47
+ },
48
+ // Values, not schemas: a "$ref" key inside default, examples, const or enum is data, and a property named "$ref" is a name.
49
+ { name: 'a "$ref" inside a default (a value)', schema: { type: "object", properties: { d: { type: "object", default: { $ref: "data-template.json" } } } }, refusedAt: [] },
50
+ { name: 'a "$ref" inside examples (values)', schema: { type: "object", properties: { d: { type: "object", examples: [{ $ref: "https://example.com/x.json" }] } } }, refusedAt: [] },
51
+ { name: 'a "$ref" inside a const (a value)', schema: { type: "object", properties: { c: { const: { $ref: "other.json#/y" } } } }, refusedAt: [] },
52
+ { name: 'a "$ref" inside an enum (values)', schema: { type: "object", properties: { e: { enum: [{ $ref: "z.json" }, null] } } }, refusedAt: [] },
53
+ { name: 'a property named "$ref" (a name)', schema: { type: "object", properties: { $ref: { type: "string" } } }, refusedAt: [] },
54
+ // What may be used.
55
+ { name: "an empty object", schema: { type: "object" }, refusedAt: [] },
56
+ { name: "an open object", schema: { type: "object", additionalProperties: true }, refusedAt: [] },
57
+ {
58
+ name: "a generated schema (titles, defaults, formats, bounds, a nullable union, prefixItems)",
59
+ schema: {
60
+ type: "object",
61
+ title: "Input",
62
+ properties: {
63
+ q: { type: "string", title: "Q", minLength: 1, pattern: "^[a-z]+$" },
64
+ when: { anyOf: [{ type: "string", format: "date-time" }, { type: "null" }], default: null },
65
+ pair: { type: "array", prefixItems: [{ type: "string" }, { type: "number" }], items: false },
66
+ n: { type: "integer", minimum: 0, maximum: 4 },
67
+ },
68
+ required: ["q"],
69
+ additionalProperties: false,
70
+ },
71
+ refusedAt: [],
72
+ },
73
+ ]);
@@ -0,0 +1,3 @@
1
+ /** Where `schema`, the input schema of the tool `tool`, is refused on every harness, as `<tool>: <JSON pointer>: <why>`;
2
+ * empty when it may be used. */
3
+ export declare function toolSchemaProblems(tool: string, schema: unknown): string[];