@percepteye/agent-flywheel 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.
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Every control-plane call this package makes, and the one that must never be
3
+ * replayed.
4
+ *
5
+ * NO COUNT IN THAT SENTENCE, deliberately. It used to open "the nine
6
+ * control-plane calls" and went stale the moment `captureCurrent` was added --
7
+ * a reviewer counting the header against the served-route list would have been
8
+ * counting nine of ten. What keeps this file's surface honest is not a number
9
+ * in a comment but `test/config-gate.test.js`, which parses every
10
+ * `request("METHOD", "<path>")` out of THIS SOURCE and asserts the control
11
+ * plane serves it. A count restates that check without performing it.
12
+ *
13
+ * A port of `agent_flywheel/transport.py`. Everything here is
14
+ * OUTBOUND-ONLY: there is no listening socket anywhere in this package, and a
15
+ * customer's agent opens every connection.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+
19
+ import { isLowerSha256 } from "./execution-identity.js";
20
+ import {
21
+ ContractError, PRODUCTION_TURN_BATCH_MAX_ITEMS, productionAgentIdentifier,
22
+ productionIdentifier, productionTurnIdentifier, rolloutOutputToWire,
23
+ rolloutRequestFromWire,
24
+ } from "./wire.js";
25
+
26
+ /**
27
+ * `json.dumps(value, sort_keys=True)`, in JavaScript -- for STRINGS, BOOLEANS,
28
+ * NULLS AND INTEGERS. Numbers that are not integers are the one gap, and it is
29
+ * named below rather than left to be found by a digest that does not match.
30
+ *
31
+ * THE IDEMPOTENCY KEY IS A CROSS-SDK CONTRACT, and this function is the whole
32
+ * of it. Both packages hash a batch of turns and send the digest as that
33
+ * batch's `Idempotency-Key`; a customer who migrates harnesses with turns
34
+ * still on disk gets each SDK defeating the other's de-duplication if the two
35
+ * hash the same payload differently.
36
+ *
37
+ * `JSON.stringify` differs from Python's default in three ways that ARE
38
+ * handled here, and this shipped claiming a parity it did not have until a
39
+ * test in the Python package (`test_report_turns_wire.py`) hashed one real
40
+ * batch through both ends and compared the digests:
41
+ *
42
+ * 1. KEY ORDER -- Python sorts; JS preserves insertion order.
43
+ * 2. SEPARATORS -- Python's defaults are ", " and ": ", WITH the spaces.
44
+ * 3. NON-ASCII -- Python's `ensure_ascii=True` escapes every non-ASCII
45
+ * codepoint; JS emits it literally. This is the one that bites in
46
+ * practice, because what is being hashed is end users' own messages.
47
+ *
48
+ * NUMBERS ARE A FOURTH WAY, AND THIS SIDE ALONE CANNOT CLOSE IT. Python
49
+ * renders a float with `repr`, JS with `Number.prototype.toString`, and they
50
+ * disagree on more than spacing (each verified by running both):
51
+ *
52
+ * 12.0 -> Python `12.0` Node `12`
53
+ * 1e-7 -> Python `1e-07` Node `1e-7`
54
+ * 1e16 -> Python `1e+16` Node `10000000000000000`
55
+ * -0.0 -> Python `-0.0` Node `0`
56
+ *
57
+ * The first row is the one that matters and is also the reason a JS-side
58
+ * float formatter would be a pretence rather than a fix:
59
+ * `JSON.parse('{"latency_ms": 12.0}')` yields the number 12 with int-vs-float
60
+ * ALREADY ERASED, so nothing here can know which of the two forms Python would
61
+ * have emitted for the same file. This is reachable, not theoretical -- a tool
62
+ * call's `arguments` and `latency_ms` are the customer's own JSON, read back
63
+ * off disk.
64
+ *
65
+ * Closing it needs ONE canonicaliser applied on BOTH sides before hashing, so
66
+ * it is a change to the Python package too; a second number formatter here
67
+ * would fix the two rare rows and leave the common one broken while looking
68
+ * settled. Until then the guarantee is exactly: a batch of strings, booleans,
69
+ * nulls and integers hashes identically on both sides, and a batch carrying a
70
+ * non-integral or very large number may not -- in which case the replay is
71
+ * caught by the server's per-turn (org, agent, turn_id) rule, which this key
72
+ * sits in front of rather than replaces. `test/transport.test.js` pins the gap
73
+ * so it stays visible instead of being certified away by an integers-only
74
+ * fixture.
75
+ */
76
+ function stableStringify(value) {
77
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(", ")}]`;
78
+ if (value && typeof value === "object") {
79
+ const body = Object.keys(value).sort()
80
+ .map((k) => `${pyStr(k)}: ${stableStringify(value[k])}`)
81
+ .join(", ");
82
+ return `{${body}}`;
83
+ }
84
+ if (typeof value === "string") return pyStr(value);
85
+ if (value === undefined) return "null";
86
+ return JSON.stringify(value);
87
+ }
88
+
89
+ /** Everything `ensure_ascii=True` escapes and `JSON.stringify` does not. */
90
+ const NON_ASCII = new RegExp("[\\u007f-\\uffff]", "g");
91
+
92
+ /** One string, escaped the way `json.dumps(ensure_ascii=True)` escapes it. */
93
+ function pyStr(s) {
94
+ // JSON.stringify already escapes quotes, backslashes and control characters
95
+ // exactly as Python does; only the non-ASCII codepoints must be folded down.
96
+ return JSON.stringify(s).replace(
97
+ NON_ASCII,
98
+ (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
99
+ );
100
+ }
101
+
102
+ const clampInt = (v, lo, hi) => Math.max(lo, Math.min(hi, Number(v) || lo));
103
+
104
+ /** `urllib.parse.quote(value, safe="-._~")`, for cross-SDK keys. */
105
+ function quoteIdentifier(value) {
106
+ return encodeURIComponent(value).replace(
107
+ /[!'()*]/g,
108
+ (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
109
+ );
110
+ }
111
+
112
+ /** One opaque, control-plane-authored rollout id as a safe URL segment. */
113
+ function rolloutPath(rollout, suffix) {
114
+ const rolloutId = rollout?.rolloutId;
115
+ if (typeof rolloutId !== "string" || !rolloutId) {
116
+ throw new ContractError("rollout_id must be a non-empty string");
117
+ }
118
+ try {
119
+ return `/rollouts/${quoteIdentifier(rolloutId)}/${suffix}`;
120
+ } catch {
121
+ throw new ContractError("rollout_id contains invalid Unicode");
122
+ }
123
+ }
124
+
125
+ /** Bounded, header-safe idempotency identity for an opaque rollout id. */
126
+ function rolloutOperationKey(operation, rollout) {
127
+ const rolloutId = rollout?.rolloutId;
128
+ if (typeof rolloutId !== "string" || !rolloutId) {
129
+ throw new ContractError("rollout_id must be a non-empty string");
130
+ }
131
+ const digest = createHash("sha256").update(rolloutId, "utf8").digest("hex");
132
+ return `${operation}-${digest}`;
133
+ }
134
+
135
+ export class AttachTransport {
136
+ /**
137
+ * @param {import("./http.js").ControlPlaneClient} client
138
+ * @param {{agentId: string, waitMs?: number, leaseMs?: number}} opts
139
+ */
140
+ constructor(client, { agentId, waitMs = 5000, leaseMs = 1_800_000 } = {}) {
141
+ if (!agentId) throw new Error("agentId is required");
142
+ this._c = client;
143
+ this.agentId = agentId;
144
+ this.waitMs = clampInt(waitMs, 0, 25_000);
145
+ this.leaseMs = clampInt(leaseMs, 1_000, 1_800_000);
146
+ // Activated only after an authenticated registration succeeds. A failed
147
+ // or legacy registration cannot make this worker claim an exact identity.
148
+ this.agentExecutionSha256 = null;
149
+ }
150
+
151
+ health() {
152
+ return this._c.request("GET", "/health");
153
+ }
154
+
155
+ /**
156
+ * Create or update this agent's row. THE ONLY PRODUCER of one -- no other
157
+ * route in the control plane creates a `FlywheelAgent`, so an agent that
158
+ * never registers cannot be consented to, graded or described. Replayable:
159
+ * the route is an upsert and the key collapses a network-level resend.
160
+ *
161
+ * The payload is built by its caller, `capture.js:enrol`, which is where
162
+ * the three-state `discovered_agent` decision is made and explained.
163
+ */
164
+ async register(spec) {
165
+ const response = await this._c.request("POST", "/agents", {
166
+ jsonBody: spec,
167
+ idempotencyKey: `reg-${quoteIdentifier(this.agentId)}`,
168
+ });
169
+ const executionSha = spec?.agent_execution_sha256;
170
+ this.agentExecutionSha256 = isLowerSha256(executionSha)
171
+ ? executionSha
172
+ : null;
173
+ return response;
174
+ }
175
+
176
+ /**
177
+ * What policy this agent should be running, per the control plane.
178
+ *
179
+ * FAILS OPEN, deliberately. A control plane that cannot answer must not stop
180
+ * an agent from running, so an empty answer means "keep doing what you were
181
+ * doing" -- exactly the behaviour before this endpoint existed.
182
+ *
183
+ * ⚠ `policy.prompt` IS NOT PROMPT TEXT. It is the control plane's own
184
+ * prompt-PIN identity snapshot (`{pins, digest, captured_at}`), unrelated to
185
+ * the customer's optimized prompt; the text comes from `promptCurrent`
186
+ * below. CONSUMER: `policy.js`, which reads `endpoint`, `arm` and
187
+ * `serving_generation` and reports what it cannot apply.
188
+ */
189
+ async policyCurrent() {
190
+ try {
191
+ return (await this._c.request("GET", "/policy/current")) || {};
192
+ } catch {
193
+ return {};
194
+ }
195
+ }
196
+
197
+ /**
198
+ * The approved optimized system prompt, or `{}`.
199
+ *
200
+ * Same fail-open contract, and for a sharper reason: a control plane that
201
+ * cannot answer must never BLANK a running agent's instructions. `{}` means
202
+ * KEEP THE PROMPT YOU HAVE; it never means "use no prompt".
203
+ *
204
+ * CONSUMER: `policy.js:createPolicySource.resolve`, which decides whether
205
+ * the answer may be applied and returns it to the host from
206
+ * `before_prompt_build`. Both this method and `policyCurrent` above shipped
207
+ * with NO consumer at all -- two reads whose answers were thrown away, which
208
+ * is the same defect as a route nobody serves, pointed the other way.
209
+ */
210
+ async promptCurrent() {
211
+ try {
212
+ return (await this._c.request("GET", "/prompt/current")) || {};
213
+ } catch {
214
+ return {};
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Claim up to `maxRollouts` rollouts. Returns `{rollouts, pollAfterMs}`.
220
+ *
221
+ * CLAIMING IS NOT REPLAYABLE, and this is the one call in the package that
222
+ * turns ambiguous retries OFF. The control plane does not honour
223
+ * Idempotency-Key on this route, so resending a claim that MAY already have
224
+ * been processed leases a SECOND batch while the first is held by nobody --
225
+ * those rollouts then sit untouched until their leases lapse. Only a failure
226
+ * that provably never reached the server is retried.
227
+ */
228
+ async claim(maxRollouts) {
229
+ const jsonBody = {
230
+ agent_id: this.agentId,
231
+ max: clampInt(maxRollouts, 1, 16),
232
+ wait_ms: this.waitMs,
233
+ lease_ms: this.leaseMs,
234
+ };
235
+ if (this.agentExecutionSha256 !== null) {
236
+ jsonBody.agent_execution_sha256 = this.agentExecutionSha256;
237
+ }
238
+ const body = await this._c.request("POST", "/rollouts/claim", {
239
+ jsonBody,
240
+ idempotencyKey: crypto.randomUUID(),
241
+ timeoutMs: this.waitMs + 10_000,
242
+ retryIfAmbiguous: false,
243
+ });
244
+ const raw = body.rollouts || [];
245
+ return {
246
+ rollouts: raw.map(rolloutRequestFromWire),
247
+ pollAfterMs: Number(body.poll_after_ms) || 1000,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * May this key upload this agent's production turns, right now?
253
+ *
254
+ * A READ, and that is the point. The verdict used to be reachable only
255
+ * through `POST /agents` -- an upsert -- so re-asking cost a write, and a
256
+ * registration that omits `discovered_agent` is recorded as introspection
257
+ * `disabled`, which stops that agent's description driving workflow
258
+ * generation.
259
+ *
260
+ * `register` above is still called, ONCE, at startup: it is the only
261
+ * producer of the agent row this verdict is computed from, and without it
262
+ * the answer is permanently "not registered". Re-asking is what this route
263
+ * made free, not registering. See `capture.js:enrol`.
264
+ *
265
+ * FAILS CLOSED. An unreachable control plane resolves to `{}`, which the
266
+ * caller reads as "no". The opposite of `policyCurrent`, which fails OPEN
267
+ * because keeping the current policy is the safe answer there -- the two
268
+ * look alike and their safe directions are opposite, which is why this one
269
+ * says so.
270
+ */
271
+ async captureCurrent(agentId) {
272
+ if (productionAgentIdentifier(agentId) === null) return {};
273
+ try {
274
+ return (await this._c.request(
275
+ "GET", `/agents/${encodeURIComponent(agentId)}/capture`)) || {};
276
+ } catch {
277
+ return {};
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Upload a batch of captured PRODUCTION turns.
283
+ *
284
+ * REPLAYABLE, unlike `claim`. The server keys each turn on
285
+ * (org, agent, turn_id) and answers an identical resubmission as a
286
+ * duplicate, so an ambiguous retry costs nothing -- whereas a replayed claim
287
+ * would lease a second piece of work. This therefore leaves the client's
288
+ * default retry behaviour ON.
289
+ *
290
+ * The idempotency key covers the BATCH, matching
291
+ * `agent_flywheel.transport.report_turns`, so a network-level
292
+ * replay of the same bytes is collapsed before the per-turn rule is reached.
293
+ * Both ends derive it the same way -- a sha256 over the turns, first 32 hex
294
+ * characters -- because two SDKs deriving one key differently would defeat
295
+ * the collapse for whichever customer happened to switch harnesses.
296
+ */
297
+ reportTurns(agentId, turns) {
298
+ if (productionAgentIdentifier(agentId) === null) {
299
+ throw new ContractError(
300
+ "production turn agent_id does not match the published production agent identifier grammar",
301
+ );
302
+ }
303
+ if (
304
+ !Array.isArray(turns)
305
+ || turns.length < 1
306
+ || turns.length > PRODUCTION_TURN_BATCH_MAX_ITEMS
307
+ ) {
308
+ throw new ContractError(
309
+ `production turn batches must contain between 1 and ${PRODUCTION_TURN_BATCH_MAX_ITEMS} turns`,
310
+ );
311
+ }
312
+ turns.forEach((turn, index) => {
313
+ if (!turn || typeof turn !== "object" || Array.isArray(turn)) {
314
+ throw new ContractError(`production turn ${index} must be an object`);
315
+ }
316
+ if (productionTurnIdentifier(turn.turn_id) === null) {
317
+ throw new ContractError(`production turn ${index} has an invalid turn_id`);
318
+ }
319
+ if (
320
+ Object.hasOwn(turn, "conversation_id")
321
+ && productionIdentifier(turn.conversation_id) === null
322
+ ) {
323
+ throw new ContractError(
324
+ `production turn ${index} has an invalid conversation_id`,
325
+ );
326
+ }
327
+ if (
328
+ Object.hasOwn(turn, "task_id")
329
+ && productionIdentifier(turn.task_id) === null
330
+ ) {
331
+ throw new ContractError(`production turn ${index} has an invalid task_id`);
332
+ }
333
+ });
334
+ const digest = createHash("sha256")
335
+ .update(stableStringify(turns))
336
+ .digest("hex")
337
+ .slice(0, 32);
338
+ return this._c.request("POST", "/turns", {
339
+ jsonBody: { agent_id: agentId, turns },
340
+ idempotencyKey: `turns-${quoteIdentifier(agentId)}-${digest}`,
341
+ });
342
+ }
343
+
344
+ /**
345
+ * Renew the lease on a rollout that is STILL RUNNING.
346
+ *
347
+ * A REQUIREMENT ON WHOEVER HOLDS THE ROLLOUT, not an optional ping. The
348
+ * control plane reaps a lease that stops being renewed and requeues the work
349
+ * while attempts remain, so a rollout that outlives `leaseMs` -- 30 minutes by
350
+ * default, routine for a multi-tool task -- is handed to somebody else while
351
+ * this process is still working on it. The result then lands against a lease
352
+ * this process no longer owns: `report` is answered for an attempt that was
353
+ * superseded, and `heartbeat` itself raises `LeaseLost` (HTTP 410), which is
354
+ * the signal to stop working and report nothing rather than to retry.
355
+ *
356
+ * So a caller that keeps a rollout across an await must beat it at roughly
357
+ * `leaseMs / 3` until it reports or gives it back. The Python end does this
358
+ * from `RolloutRunner`, pinned by
359
+ * `tests/test_lease_and_concurrency.py::test_a_long_rollout_heartbeats_its_lease`.
360
+ */
361
+ heartbeat(rollout, { progress } = {}) {
362
+ const payload = {
363
+ lease_token: rollout.leaseToken,
364
+ extend_ms: Math.min(this.leaseMs, 600_000),
365
+ };
366
+ if (progress) payload.progress = progress;
367
+ return this._c.request(
368
+ "POST", rolloutPath(rollout, "heartbeat"), { jsonBody: payload },
369
+ );
370
+ }
371
+
372
+ /**
373
+ * Report the result. REPLAYABLE: the server keys on (rollout_id, attempt),
374
+ * so an identical replay is acknowledged as a duplicate rather than
375
+ * double-counted -- which is why this one keeps ambiguous retries ON.
376
+ */
377
+ report(rollout, output) {
378
+ return this._c.request("POST", rolloutPath(rollout, "result"), {
379
+ jsonBody: rolloutOutputToWire(output),
380
+ idempotencyKey: rolloutOperationKey("result", rollout),
381
+ headers: rollout.leaseToken
382
+ ? { "X-Percepteye-Lease-Token": rollout.leaseToken }
383
+ : null,
384
+ });
385
+ }
386
+
387
+ /**
388
+ * Give the work back, TYPED.
389
+ *
390
+ * This is not a low reward. It is "do not train on this" -- a substrate
391
+ * fault, an expired credential, a crash. Reporting a crashed rollout as a
392
+ * zero-scoring one teaches the policy that its own behaviour caused an
393
+ * outcome it had nothing to do with.
394
+ */
395
+ abandon(rollout, { reason, detail = "" }) {
396
+ if (!reason) throw new Error("abandon requires a typed reason");
397
+ return this._c.request("POST", rolloutPath(rollout, "abandon"), {
398
+ jsonBody: { lease_token: rollout.leaseToken, reason, detail },
399
+ idempotencyKey: rolloutOperationKey("abandon", rollout),
400
+ });
401
+ }
402
+
403
+ }