@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.
- package/LICENSE +21 -0
- package/README.md +368 -0
- package/cordis.patch.yml +11 -0
- package/openclaw.plugin.json +134 -0
- package/package.json +67 -0
- package/schema/flywheel-1.json +432 -0
- package/src/capture.js +733 -0
- package/src/classify.js +115 -0
- package/src/config.js +249 -0
- package/src/describe.js +355 -0
- package/src/dsh-classify.js +110 -0
- package/src/dsh.js +130 -0
- package/src/errors.js +37 -0
- package/src/evidence.js +109 -0
- package/src/execution-identity.js +444 -0
- package/src/host.js +63 -0
- package/src/http.js +249 -0
- package/src/index.js +380 -0
- package/src/mode.js +152 -0
- package/src/model-calls.js +214 -0
- package/src/policy.js +934 -0
- package/src/record.js +83 -0
- package/src/rollout.js +884 -0
- package/src/scope.js +242 -0
- package/src/session.js +42 -0
- package/src/trajectory.js +148 -0
- package/src/transport.js +403 -0
- package/src/turns.js +437 -0
- package/src/unattended.js +251 -0
- package/src/wire.js +182 -0
package/src/transport.js
ADDED
|
@@ -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
|
+
}
|