@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/src/rollout.js ADDED
@@ -0,0 +1,884 @@
1
+ /**
2
+ * TRAINING MODE: claim a rollout, hand it to the agent, report what happened.
3
+ *
4
+ * This is the half of the package that only exists in `training` mode. In
5
+ * `production` mode `registerRolloutDriver` is NEVER CALLED, so none of these
6
+ * hooks are subscribed and none of these calls can happen -- see `mode.js` for
7
+ * why the split is the absence of a subscription rather than a branch inside a
8
+ * handler.
9
+ *
10
+ * ── WHAT THE HOST ACTUALLY ALLOWS A THIRD-PARTY PLUGIN ────────────────────
11
+ *
12
+ * Every claim below was read out of the installed host (OpenClaw 2026.7.1-2),
13
+ * not inferred from its documentation, because three of them are the opposite
14
+ * of what the API surface suggests.
15
+ *
16
+ * `enqueueNextTurnInjection` AVAILABLE. `enqueuePluginNextTurnInjection`
17
+ * (registry-B8eQDFB4.js:1405) has NO origin check, and
18
+ * `isPluginPromptInjectionEnabled` (:1389) is `!== false` -- DEFAULT-ALLOW.
19
+ * So delivering a task works on a default install, and it returns
20
+ * `{enqueued, id, sessionKey}`, which is a real verdict we can act on rather
21
+ * than a fire-and-forget. Limits are 32 KiB of text and 32 queued
22
+ * injections per session (:1368, :1370); over either, the host returns
23
+ * `enqueued:false` SILENTLY, which is why the size is checked here.
24
+ *
25
+ * `scheduleSessionTurn` NOT AVAILABLE. `schedulePluginSessionTurn`
26
+ * (:1186) opens with `if (params.origin !== "bundled") return;` -- a silent
27
+ * no-op for any npm-installed plugin, returning `undefined`, which is
28
+ * indistinguishable from "cron service unavailable".
29
+ *
30
+ * BUT THAT GATE IS ON THE WRAPPER, NOT ON THE CAPABILITY, and an earlier
31
+ * version of this comment drew the wrong conclusion from it -- that this
32
+ * plugin cannot start a turn at all. It can. `gateway_start` and
33
+ * `cron_changed` hand every plugin, whatever its origin, the RAW host cron
34
+ * service as `ctx.getCron()` (server-startup-post-attach-B3O9knW5.js:765,
35
+ * server-cron-Cwg2hJro.js:4384), `registerTypedHook` gates only conversation
36
+ * hooks by origin and prompt-injection hooks by policy -- neither covers
37
+ * `gateway_start` -- and `server-cron` contains no origin check anywhere. A
38
+ * job with `payload.kind: "agentTurn"` and `sessionTarget: "session:<key>"`
39
+ * reaches `runIsolatedAgentJob` (:2433) and then `runCronIsolatedAgentTurn`
40
+ * (:4484), which runs the agent.
41
+ *
42
+ * THIS LANE USES THAT PATH, IN TRAINING MODE, AND SAYS SO. An earlier
43
+ * version of this header said the opposite -- "deliberately does not use
44
+ * that path" -- and it stayed there after `registerRolloutDriver` below
45
+ * started wiring it, which made the most sensitive claim in the package a
46
+ * false one. Reaching past a restriction the host states explicitly
47
+ * ("Bundled-only") by way of a service it hands out elsewhere is depending
48
+ * on what looks like an inconsistency in someone else's permission model,
49
+ * so it is done as a decision with three things designed in:
50
+ *
51
+ * - TRAINING MODE ONLY, structurally: this function is not called at all
52
+ * in production, so a process serving real users can never start a turn
53
+ * on its own, whatever any setting says.
54
+ * - IN A DEDICATED SESSION, `percepteye-rollout-<id>`, never one a person
55
+ * is using, and with `delivery.mode: "none"` so training output cannot
56
+ * surface in a human's chat.
57
+ * - DEGRADING WHEN THE SEAM CLOSES, which a patch release may do at any
58
+ * time: the runner stops polling and this file's injection lane carries
59
+ * on alone. See `unattended.js` for the mechanism and why stopping (not
60
+ * just announcing) is the whole of it.
61
+ *
62
+ * The injection lane below is NOT replaced by any of that. It stays
63
+ * subscribed and it is what runs when there is no cron: the task is placed
64
+ * in the NEXT turn the operator or harness starts. The Python SDK's CLI-hook
65
+ * lane has the same shape -- there a SessionStart hook writes the task into
66
+ * the working directory -- so the parity statement holds; this lane simply
67
+ * delivers into the agent's real context, exactly once, with a delivery
68
+ * verdict.
69
+ *
70
+ * `agent_end` CONVERSATION-GATED. It is in
71
+ * `CONVERSATION_HOOK_NAMES`, and a non-bundled plugin's registration is
72
+ * dropped with a host-side warning unless
73
+ * `plugins.entries.<id>.hooks.allowConversationAccess=true` (:4225-4235).
74
+ * The refusal is a bare `return` -- `api.on()` reports nothing either way --
75
+ * so it cannot be detected after the fact and is read from `api.config`
76
+ * instead, using the host's own predicate.
77
+ *
78
+ * llm_input / llm_output are available, but they bracket a host attempt
79
+ * rather than each provider invocation and carry no call id. Tool loops,
80
+ * retries, and compaction can issue several provider calls inside one
81
+ * attempt. This adapter therefore makes no llm_call_count claim. A custom
82
+ * host adapter may inject the generic counter only with true per-call evidence.
83
+ *
84
+ * A claimed rollout is a LEASE on work nobody else can take. Claiming one we
85
+ * cannot report is worse than not claiming it: the task sits unrunnable until
86
+ * the lease lapses, and the queue looks busy while nothing progresses. So
87
+ * conversation access is part of `canDrive` rather than something discovered
88
+ * at report time -- and when it is missing the operator gets one line naming
89
+ * the exact setting, instead of a queue that quietly stops moving.
90
+ */
91
+ import { ConfigurationError, LeaseLost } from "./errors.js";
92
+ import { isLowerSha256 } from "./execution-identity.js";
93
+ import { conversationAccessGranted, lastAssistantText } from "./host.js";
94
+ import { SDK_UA } from "./http.js";
95
+ import { createModelCallCounter } from "./model-calls.js";
96
+ import { createUnattendedRunner, cronTurnStarter } from "./unattended.js";
97
+ import { CONTRACT_VERSION } from "./wire.js";
98
+
99
+ export const SESSION_START = "session_start";
100
+ export const SESSION_END = "session_end";
101
+ export const AGENT_END = "agent_end";
102
+ export const BEFORE_AGENT_RUN = "before_agent_run";
103
+ export const BEFORE_TOOL_CALL = "before_tool_call";
104
+ export const GATEWAY_START = "gateway_start";
105
+ export const GATEWAY_STOP = "gateway_stop";
106
+
107
+ /** The host's own ceilings, so an oversized task is refused, not dropped. */
108
+ export const MAX_INJECTION_BYTES = 32 * 1024;
109
+
110
+ /**
111
+ * Everything that must be true before this install claims work.
112
+ *
113
+ * Returns `{canDrive, reason}` -- and the reason names ONE cause, the first
114
+ * unmet one, rather than listing every possible cause for all of them. An
115
+ * operator reading "set allowConversationAccess" acts; an operator reading
116
+ * "check your mode, key and hooks" opens three files.
117
+ */
118
+ export function driveVerdict(config, api, pluginId) {
119
+ if (config?.mode !== "training") {
120
+ return {
121
+ canDrive: false,
122
+ reason:
123
+ `mode is '${config?.mode}', so this install claims no rollouts. ` +
124
+ `Set PERCEPTEYE_AGENT_MODE=training to contribute.`,
125
+ };
126
+ }
127
+ if (!config?.apiKey) {
128
+ return {
129
+ canDrive: false,
130
+ reason:
131
+ "training mode is set but no API key resolved, so there is no control " +
132
+ "plane to claim from. Set PERCEPTEYE_API_KEY, or " +
133
+ `plugins.entries.${pluginId}.config.apiKey.`,
134
+ };
135
+ }
136
+ if (!conversationAccessGranted(api, pluginId)) {
137
+ return {
138
+ canDrive: false,
139
+ reason:
140
+ `rollouts need the agent's ANSWER, which arrives on the '${AGENT_END}' ` +
141
+ `hook, and this host drops that hook for non-bundled plugins unless ` +
142
+ `plugins.entries.${pluginId}.hooks.allowConversationAccess=true. ` +
143
+ `Claiming work we cannot report would hold a lease until it lapsed, ` +
144
+ `so nothing is claimed until that is set.`,
145
+ };
146
+ }
147
+ return { canDrive: true, reason: null };
148
+ }
149
+
150
+ /** The task, as the agent will read it. */
151
+ export function taskText(rollout) {
152
+ return (
153
+ `A PerceptEye rollout has been assigned to this session.\n\n` +
154
+ `Rollout id: ${rollout.rolloutId}\n\n` +
155
+ `Task:\n${rollout.turnInput}\n`
156
+ );
157
+ }
158
+
159
+ /**
160
+ * The claim-deliver-report loop, with no host bound to it.
161
+ *
162
+ * `deps` is every host call this needs, so the loop is testable without
163
+ * constructing an OpenClaw api: `enqueue` is
164
+ * `api.session.workflow.enqueueNextTurnInjection`.
165
+ */
166
+ export function createRolloutDriver({
167
+ transport, enqueue, cancel = null, logger = null, executionIdentity = null,
168
+ modelCalls = createModelCallCounter(),
169
+ heartbeatScheduler = (callback, delayMs) => {
170
+ const timer = setTimeout(callback, delayMs);
171
+ timer.unref?.();
172
+ return timer;
173
+ },
174
+ heartbeatCanceller = (timer) => clearTimeout(timer),
175
+ now = () => Date.now(),
176
+ }) {
177
+ if (!transport) throw new ConfigurationError("a transport is required");
178
+ if (typeof enqueue !== "function") {
179
+ throw new ConfigurationError("an enqueue function is required");
180
+ }
181
+ if (cancel !== null && typeof cancel !== "function") {
182
+ throw new ConfigurationError("cancel must be a function when supplied");
183
+ }
184
+
185
+ /** sessionKey -> the rollout this session is running. */
186
+ const inFlight = new Map();
187
+ /** sessionKey -> fixed-cadence lease renewal for its current rollout. */
188
+ const heartbeats = new Map();
189
+ /** sessionKey -> host run id (or null), once the injected turn starts. */
190
+ const activeRuns = new Map();
191
+ /**
192
+ * sessionKey -> rollout whose terminal result request is in progress.
193
+ *
194
+ * A heartbeat is deliberately kept alive through that request. The result
195
+ * may close the lease just before an overlapping heartbeat reaches the
196
+ * server, in which case that heartbeat's 410 is completion, not revocation.
197
+ * Keeping this state separate prevents the completed turn from fencing the
198
+ * next unrelated run in the same session.
199
+ */
200
+ const reporting = new Map();
201
+ /**
202
+ * A lease lost before model submission. `agent_turn_prepare` drains the
203
+ * exactly-once injection before `before_agent_run`, so blocking that one run
204
+ * both prevents stale work and consumes the now-invalid injection.
205
+ */
206
+ const blockNextRun = new Set();
207
+ /** sessionKey -> revoked host run id (or null when the host omitted it). */
208
+ const revokedRuns = new Map();
209
+ /**
210
+ * Sessions with a claim in flight but not yet recorded.
211
+ *
212
+ * SEPARATE FROM `inFlight`, because `inFlight` cannot be populated until the
213
+ * claim returns and the guard has to hold across that await. Checking
214
+ * `inFlight` alone let two `session_start` events for one session both pass
215
+ * the check before either finished claiming -- two leases for one session,
216
+ * of which only the second could ever be delivered or reported, with the
217
+ * first left to lapse.
218
+ */
219
+ const claiming = new Set();
220
+ let warnedMissingModelCallCount = false;
221
+
222
+ function stopHeartbeat(sessionKey) {
223
+ const state = heartbeats.get(sessionKey);
224
+ if (!state) return;
225
+ state.stopped = true;
226
+ if (state.timer !== null) heartbeatCanceller(state.timer);
227
+ heartbeats.delete(sessionKey);
228
+ }
229
+
230
+ function heartbeatIntervalFor(rollout) {
231
+ // leaseExpiresTs is the absolute expiry the server actually granted.
232
+ // Measure the duration ONCE: recomputing against a shrinking absolute
233
+ // timestamp makes the interval collapse toward one second forever.
234
+ const grantedMs = Number.isFinite(rollout?.leaseExpiresTs)
235
+ ? rollout.leaseExpiresTs * 1000 - now()
236
+ : null;
237
+ const requestedMs = Number(transport?.leaseMs);
238
+ const leaseMs = grantedMs !== null && grantedMs > 0
239
+ ? grantedMs
240
+ : (Number.isFinite(requestedMs) && requestedMs > 0
241
+ ? requestedMs : 1_800_000);
242
+ // The server accepts at most a ten-minute extension. A 30-minute initial
243
+ // grant therefore cannot pace at 30m/3: that would schedule the next beat
244
+ // exactly when the renewed 10-minute lease expires. Pace against the
245
+ // smaller of the granted/requested duration and the renewal ceiling.
246
+ return Math.max(1_000, Math.min(leaseMs, 600_000) / 3);
247
+ }
248
+
249
+ function startHeartbeat(sessionKey, rollout) {
250
+ stopHeartbeat(sessionKey);
251
+ if (!rollout?.leaseToken || typeof transport?.heartbeat !== "function") return;
252
+ const state = {
253
+ rollout,
254
+ intervalMs: heartbeatIntervalFor(rollout),
255
+ stopped: false,
256
+ timer: null,
257
+ };
258
+ heartbeats.set(sessionKey, state);
259
+ const schedule = () => {
260
+ if (state.stopped) return;
261
+ state.timer = heartbeatScheduler(beat, state.intervalMs);
262
+ };
263
+ const beat = async () => {
264
+ if (state.stopped) return;
265
+ state.timer = null;
266
+ try {
267
+ await transport.heartbeat(rollout);
268
+ } catch (err) {
269
+ if (err instanceof LeaseLost || err?.name === "LeaseLost") {
270
+ state.stopped = true;
271
+ heartbeats.delete(sessionKey);
272
+ if (reporting.get(sessionKey) === rollout) {
273
+ // The terminal result request races this heartbeat. It owns the
274
+ // authoritative outcome now; a 410 here can simply mean that the
275
+ // result landed first and closed the lease. The agent run is
276
+ // already over, so there is nothing to cancel or fence.
277
+ return;
278
+ }
279
+ if (inFlight.get(sessionKey) === rollout) inFlight.delete(sessionKey);
280
+ try { modelCalls?.abandon?.(sessionKey); } catch { /* evidence cleanup */ }
281
+ const runStarted = activeRuns.has(sessionKey);
282
+ const runId = activeRuns.get(sessionKey) ?? undefined;
283
+ activeRuns.delete(sessionKey);
284
+ if (!runStarted) blockNextRun.add(sessionKey);
285
+ else revokedRuns.set(sessionKey, runId ?? null);
286
+ let cancelled = false;
287
+ if (cancel) {
288
+ try {
289
+ cancelled = await cancel({ sessionKey, runId, rollout }) === true;
290
+ if (!cancelled && runStarted) {
291
+ logger?.warn?.(
292
+ `[agent-flywheel] lease lost for rollout ${rollout.rolloutId}, ` +
293
+ `but the host did not confirm that its active run stopped.`,
294
+ );
295
+ }
296
+ } catch (cancelErr) {
297
+ logger?.warn?.(
298
+ `[agent-flywheel] lease lost for rollout ${rollout.rolloutId}, and ` +
299
+ `the host could not stop its run: ${cancelErr?.message ?? cancelErr}`,
300
+ );
301
+ }
302
+ } else if (runStarted) {
303
+ logger?.warn?.(
304
+ `[agent-flywheel] lease lost for rollout ${rollout.rolloutId}; this ` +
305
+ `host exposes no active-run cancellation seam, so every later ` +
306
+ `tool call from the revoked run will be blocked.`,
307
+ );
308
+ }
309
+ logger?.warn?.(
310
+ `[agent-flywheel] lease lost for rollout ${rollout.rolloutId}; its ` +
311
+ `${cancelled ? "host run was cancelled and " : ""}result will not ` +
312
+ `be reported because the control plane requeued it.`,
313
+ );
314
+ return;
315
+ }
316
+ logger?.warn?.(
317
+ `[agent-flywheel] could not renew rollout ${rollout.rolloutId}; the next ` +
318
+ `scheduled heartbeat will retry: ${err?.message ?? err}`,
319
+ );
320
+ }
321
+ schedule();
322
+ };
323
+ schedule();
324
+ }
325
+
326
+ /**
327
+ * Give the work back, typed, and stop tracking it.
328
+ *
329
+ * ALWAYS TYPED. An abandon is "do not train on this" -- a substrate fault --
330
+ * not a low score. Reporting an undeliverable rollout as a zero-reward one
331
+ * would teach the policy that its own behaviour caused an outcome it never
332
+ * participated in.
333
+ */
334
+ async function giveBack(sessionKey, rollout, reason, detail) {
335
+ stopHeartbeat(sessionKey);
336
+ inFlight.delete(sessionKey);
337
+ try {
338
+ modelCalls?.abandon?.(sessionKey);
339
+ } catch {
340
+ /* measurement cleanup must not keep a lease from being returned */
341
+ }
342
+ try {
343
+ await transport.abandon(rollout, { reason, detail });
344
+ } catch (err) {
345
+ logger?.warn?.(
346
+ `[agent-flywheel] could not abandon rollout ${rollout.rolloutId}; its ` +
347
+ `lease will lapse on its own: ${err?.message ?? err}`,
348
+ );
349
+ }
350
+ }
351
+
352
+ /** Record a rollout as running in `sessionKey`, so `onAgentEnd` reports it. */
353
+ function track(sessionKey, rollout) {
354
+ inFlight.set(sessionKey, rollout);
355
+ startHeartbeat(sessionKey, rollout);
356
+ try {
357
+ modelCalls?.begin?.(sessionKey);
358
+ } catch {
359
+ /* the result will be reported without an attested count */
360
+ }
361
+ }
362
+
363
+ /** Generic adapter seam: one host-proven provider invocation began. */
364
+ function onModelCallStarted(event, ctx = {}) {
365
+ try {
366
+ return modelCalls?.observeStarted?.(event, ctx) === true;
367
+ } catch {
368
+ return false;
369
+ }
370
+ }
371
+
372
+ /** Generic adapter seam: the same host-proven provider invocation ended. */
373
+ function onModelCallEnded(event, ctx = {}) {
374
+ try {
375
+ return modelCalls?.observeEnded?.(event, ctx) === true;
376
+ } catch {
377
+ return false;
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Bind the host's active run to its lease, and stop a queued turn whose
383
+ * lease was revoked before it reached model submission.
384
+ */
385
+ function onBeforeAgentRun(_event, ctx = {}) {
386
+ const sessionKey = ctx?.sessionKey ?? ctx?.sessionId;
387
+ if (!sessionKey) return undefined;
388
+ if (blockNextRun.delete(sessionKey) || revokedRuns.has(sessionKey)) {
389
+ return {
390
+ outcome: "block",
391
+ reason: "the PerceptEye rollout lease was lost before this run started",
392
+ message: "This training rollout expired before it started. Please retry.",
393
+ category: "percepteye_lease_lost",
394
+ };
395
+ }
396
+ if (inFlight.has(sessionKey)) {
397
+ const runId = ctx?.runId;
398
+ activeRuns.set(
399
+ sessionKey,
400
+ typeof runId === "string" && runId ? runId : null,
401
+ );
402
+ }
403
+ return undefined;
404
+ }
405
+
406
+ /**
407
+ * OpenClaw does not expose a background active-run abort to ordinary
408
+ * plugins. Once a lease is revoked, fence all subsequent tool side effects
409
+ * from that run even when the model loop takes time to unwind.
410
+ */
411
+ function onBeforeToolCall(event, ctx = {}) {
412
+ const sessionKey = ctx?.sessionKey ?? ctx?.sessionId;
413
+ if (!sessionKey || !revokedRuns.has(sessionKey)) return undefined;
414
+ const revokedRunId = revokedRuns.get(sessionKey);
415
+ const observedRunId = event?.runId ?? ctx?.runId;
416
+ if (
417
+ revokedRunId && typeof observedRunId === "string" && observedRunId
418
+ && observedRunId !== revokedRunId
419
+ ) return undefined;
420
+ return {
421
+ block: true,
422
+ blockReason:
423
+ "PerceptEye rollout lease was revoked; post-revocation side effects are blocked",
424
+ };
425
+ }
426
+
427
+ async function onSessionStart(event) {
428
+ const sessionKey = event?.sessionKey ?? event?.sessionId;
429
+ if (
430
+ !sessionKey || blockNextRun.has(sessionKey)
431
+ || inFlight.has(sessionKey) || claiming.has(sessionKey)
432
+ ) {
433
+ return null;
434
+ }
435
+ // Reserved SYNCHRONOUSLY, before the first await, so a second event for
436
+ // this session cannot slip past the guard while the claim is in flight.
437
+ claiming.add(sessionKey);
438
+ try {
439
+ return await claimAndDeliver(sessionKey);
440
+ } finally {
441
+ claiming.delete(sessionKey);
442
+ }
443
+ }
444
+
445
+ async function claimAndDeliver(sessionKey) {
446
+ let claimed;
447
+ try {
448
+ claimed = await transport.claim(1);
449
+ } catch (err) {
450
+ // A control plane that cannot be reached must never break the agent.
451
+ // No rollout is delivered and the session runs as an ordinary one.
452
+ logger?.warn?.(
453
+ `[agent-flywheel] could not claim a rollout: ${err?.message ?? err}`,
454
+ );
455
+ return null;
456
+ }
457
+ const rollout = claimed?.rollouts?.[0];
458
+ if (!rollout) return null;
459
+
460
+ const text = taskText(rollout);
461
+ // Checked BEFORE sending, because over the ceiling the host returns
462
+ // `enqueued:false` with no reason and the task would look delivered-then-
463
+ // rejected rather than too big.
464
+ if (Buffer.byteLength(text, "utf8") > MAX_INJECTION_BYTES) {
465
+ await giveBack(
466
+ sessionKey, rollout, "substrate_unavailable",
467
+ `task is ${Buffer.byteLength(text, "utf8")} bytes; this host caps a ` +
468
+ `next-turn injection at ${MAX_INJECTION_BYTES}`,
469
+ );
470
+ return null;
471
+ }
472
+
473
+ track(sessionKey, rollout);
474
+
475
+ let result;
476
+ try {
477
+ result = await enqueue({
478
+ sessionKey,
479
+ text,
480
+ // The rollout id, so a retried claim of the SAME rollout cannot
481
+ // deliver the task twice into one session. The host dedupes on this.
482
+ idempotencyKey: `percepteye-rollout-${rollout.rolloutId}`,
483
+ placement: "prepend_context",
484
+ });
485
+ } catch (err) {
486
+ await giveBack(
487
+ sessionKey, rollout, "entrypoint_error",
488
+ `the host refused the task injection: ${err?.message ?? err}`,
489
+ );
490
+ return null;
491
+ }
492
+
493
+ if (!result?.enqueued) {
494
+ // The agent will never see this task, so the rollout must go back now.
495
+ // Holding it until the lease lapses helps nobody and makes the queue
496
+ // look busier than it is.
497
+ await giveBack(
498
+ sessionKey, rollout, "entrypoint_error",
499
+ "the host declined to queue the task for the next turn, so the agent " +
500
+ "never received it (check " +
501
+ "plugins.entries.agent-flywheel.hooks.allowPromptInjection)",
502
+ );
503
+ return null;
504
+ }
505
+
506
+ logger?.info?.(
507
+ `[agent-flywheel] rollout ${rollout.rolloutId} delivered to session ` +
508
+ `${sessionKey}; it runs on the next turn.`,
509
+ );
510
+ return rollout;
511
+ }
512
+
513
+ /**
514
+ * The agent finished. Report the answer.
515
+ *
516
+ * `PluginHookAgentEndEvent` carries no session key -- it is
517
+ * `{runId, messages, success, error, durationMs}` -- so the key comes from
518
+ * the hook CONTEXT, which is `PluginHookSessionContext`.
519
+ *
520
+ * THIS FIRES PER ATTEMPT, NOT PER TURN. A host that retries a failed attempt
521
+ * emits it again, so the FIRST attempt is the one reported: the rollout is
522
+ * removed from `inFlight` here, and a later attempt finds nothing and
523
+ * reports nothing. That is deliberate -- reporting twice would be worse --
524
+ * but it does mean a rollout whose first attempt failed and whose retry
525
+ * succeeded is reported as the failure. Written down rather than left to be
526
+ * discovered from a confusing reward curve; reporting the final outcome
527
+ * instead needs a per-turn signal this hook does not carry.
528
+ */
529
+ async function onAgentEnd(event, ctx) {
530
+ const sessionKey = ctx?.sessionKey ?? ctx?.sessionId;
531
+ const rollout = sessionKey ? inFlight.get(sessionKey) : null;
532
+ if (sessionKey && rollout) reporting.set(sessionKey, rollout);
533
+ if (sessionKey) {
534
+ activeRuns.delete(sessionKey);
535
+ revokedRuns.delete(sessionKey);
536
+ }
537
+ if (!rollout) return null;
538
+ inFlight.delete(sessionKey);
539
+
540
+ if (event?.success === false) {
541
+ // The agent RAN and failed. That is a real attempt and exactly what
542
+ // training must see, so it is REPORTED, not abandoned -- the two are
543
+ // opposite handling and sharing one label is how a real failure gets
544
+ // triaged as a substrate fault.
545
+ logger?.info?.(
546
+ `[agent-flywheel] rollout ${rollout.rolloutId} ran and failed; reporting it.`,
547
+ );
548
+ }
549
+
550
+ const finalText = lastAssistantText(event?.messages);
551
+ let modelCallMeasurement;
552
+ try {
553
+ modelCallMeasurement = modelCalls?.finish?.(sessionKey, event, ctx);
554
+ } catch (err) {
555
+ modelCallMeasurement = {
556
+ count: null,
557
+ reason: `the model-call observer failed: ${err?.message ?? err}`,
558
+ };
559
+ }
560
+ modelCallMeasurement ??= {
561
+ count: null,
562
+ reason: "no independent model-call observer was configured",
563
+ };
564
+ if (
565
+ modelCallMeasurement.count !== null
566
+ && (!Number.isInteger(modelCallMeasurement.count)
567
+ || modelCallMeasurement.count < 0)
568
+ ) {
569
+ modelCallMeasurement = {
570
+ count: null,
571
+ reason: "the model-call observer returned an invalid count",
572
+ };
573
+ }
574
+ if (modelCallMeasurement.count === null && !warnedMissingModelCallCount) {
575
+ warnedMissingModelCallCount = true;
576
+ logger?.warn?.(
577
+ `[agent-flywheel] rollout ${rollout.rolloutId} has no exact, independently ` +
578
+ `observed model-call count (${modelCallMeasurement.reason}); it will be ` +
579
+ `reported without llm_call_count and is ineligible for verified ` +
580
+ `on-policy training.`,
581
+ );
582
+ }
583
+ try {
584
+ await transport.report(rollout, {
585
+ finalText,
586
+ // `undefined`, never `[]`. The recorder owns tool calls and writes
587
+ // them to the trajectory; asserting zero here would overwrite what it
588
+ // observed with a claim this function cannot make.
589
+ success: event?.success === true ? true : undefined,
590
+ // Counted from the host's provider-call lifecycle and reconciled by
591
+ // run/call ids. Never copied from the control plane's gateway.
592
+ llmCallCount: modelCallMeasurement.count ?? undefined,
593
+ // SDK-reconciled execution identity only. The tracker returns null on
594
+ // absent/incomplete adapter evidence or a mixed run, and this lane
595
+ // never manufactures policy identity from the current serving config.
596
+ agentFingerprint: (
597
+ executionIdentity?.consumeFingerprintFor?.(event, ctx)
598
+ ?? executionIdentity?.fingerprintFor?.(event, ctx)
599
+ ?? undefined
600
+ ),
601
+ });
602
+ return rollout;
603
+ } catch (err) {
604
+ logger?.warn?.(
605
+ `[agent-flywheel] could not report rollout ${rollout.rolloutId}: ` +
606
+ `${err?.message ?? err}`,
607
+ );
608
+ return null;
609
+ } finally {
610
+ // Keep renewing through the result POST itself; stop only once it has
611
+ // either landed or failed, not merely when the agent produced an answer.
612
+ if (reporting.get(sessionKey) === rollout) reporting.delete(sessionKey);
613
+ stopHeartbeat(sessionKey);
614
+ }
615
+ }
616
+
617
+ /**
618
+ * The session ended with a rollout still in flight, so no answer ever
619
+ * arrived. Give it back rather than reporting an empty result: a report with
620
+ * no final text is a report about nothing, and it would count as a completed
621
+ * rollout.
622
+ *
623
+ * `entrypoint_error`, NOT `cancelled`, and the difference is the whole
624
+ * point of giving it back. `cancelled` is the ONE reason Mission Control
625
+ * never requeues -- `RolloutRepository.abandon` computes
626
+ * `requeue = attempt < max_attempts and reason != "cancelled"` -- so it
627
+ * retires the rollout with its remaining attempts unused. Nothing in this
628
+ * lane is an operator cancelling anything: the ordinary case is a session
629
+ * opened and closed before a second turn, and the task never ran. That is
630
+ * exactly `entrypoint_error` ("the agent could not be STARTED -- nothing
631
+ * ran, so there is nothing to learn from and the work is worth handing
632
+ * back"), which is what the other give-back sites above already use.
633
+ */
634
+ async function onSessionEnd(event) {
635
+ const sessionKey = event?.sessionKey ?? event?.sessionId;
636
+ const rollout = sessionKey ? inFlight.get(sessionKey) : null;
637
+ if (sessionKey) {
638
+ activeRuns.delete(sessionKey);
639
+ blockNextRun.delete(sessionKey);
640
+ revokedRuns.delete(sessionKey);
641
+ }
642
+ if (!rollout) return null;
643
+ await giveBack(
644
+ sessionKey, rollout, "entrypoint_error",
645
+ `the session ended (${event?.reason ?? "unknown"}) before the agent ` +
646
+ `answered, so no trajectory was produced`,
647
+ );
648
+ return rollout;
649
+ }
650
+
651
+ // `track` and `giveBack` are what the UNATTENDED runner needs: it does its
652
+ // own claiming and turn-starting, but reporting and typed abandon must stay
653
+ // in one place, or an unattended rollout and an attended one would end two
654
+ // different ways.
655
+ return {
656
+ onSessionStart, onAgentEnd, onSessionEnd, onBeforeAgentRun,
657
+ onBeforeToolCall,
658
+ onModelCallStarted, onModelCallEnded,
659
+ inFlight, track, giveBack, heartbeats, activeRuns, blockNextRun,
660
+ revokedRuns, reporting,
661
+ };
662
+ }
663
+
664
+ /**
665
+ * Subscribe the driver. CALLED ONLY IN TRAINING MODE.
666
+ *
667
+ * Returns `null` when this install must not drive, having said why once. The
668
+ * caller does not branch on mode -- `index.js` decides that -- so there is one
669
+ * place where "may this install claim work" is answered.
670
+ */
671
+ export function registerRolloutDriver(api, {
672
+ config, transport, pluginId, logger = null, executionIdentity = null,
673
+ }) {
674
+ const verdict = driveVerdict(config, api, pluginId);
675
+ if (!verdict.canDrive) {
676
+ logger?.info?.(`[${pluginId}] not claiming rollouts: ${verdict.reason}`);
677
+ return null;
678
+ }
679
+ const enqueueFn = api?.session?.workflow?.enqueueNextTurnInjection
680
+ ?? api?.enqueueNextTurnInjection;
681
+ if (typeof enqueueFn !== "function") {
682
+ logger?.warn?.(
683
+ `[${pluginId}] this host exposes no next-turn injection API, so a ` +
684
+ `claimed task could not be delivered to the agent. Not claiming.`,
685
+ );
686
+ return null;
687
+ }
688
+
689
+ // Registration starts without blocking because OpenClaw requires plugin
690
+ // registration itself to return synchronously. The first claim in either
691
+ // lane awaits this one cached attempt, so no rollout can be leased before
692
+ // compatibility was checked and the worker was authenticated and recorded.
693
+ const registrationFingerprint = executionIdentity?.registrationFingerprint;
694
+ const registrationDescription = executionIdentity?.registrationDescription;
695
+ const registrationSpec = {
696
+ agent_id: config.agentId,
697
+ mode: "rollout",
698
+ sdk: SDK_UA,
699
+ contract_version: CONTRACT_VERSION,
700
+ reports_tool_calls: true,
701
+ isolation: "inprocess",
702
+ concurrency: 1,
703
+ input_shape: "text",
704
+ entrypoint: "openclaw-plugin",
705
+ // A startup observation is optional. When an adapter can provide one, the
706
+ // same immutable exact evidence drives both discovery and the execution
707
+ // digest registration sends before the first claim. Otherwise null says
708
+ // discovery ran but was unreadable; it never invents a worker identity.
709
+ discovered_agent: registrationDescription ?? null,
710
+ };
711
+ if (isLowerSha256(registrationFingerprint?.execution_sha256)) {
712
+ registrationSpec.agent_execution_sha256 =
713
+ registrationFingerprint.execution_sha256;
714
+ }
715
+ let registration = null;
716
+ function ensureRegistered() {
717
+ if (registration !== null) return registration;
718
+ registration = (async () => {
719
+ if (typeof transport?.health !== "function") {
720
+ throw new ConfigurationError(
721
+ "the rollout transport cannot check control-plane compatibility",
722
+ );
723
+ }
724
+ const health = await transport.health();
725
+ const served = health?.contract_versions_supported
726
+ || [health?.contract_version];
727
+ if (
728
+ !Array.isArray(served)
729
+ || !served.filter(Boolean).includes(CONTRACT_VERSION)
730
+ ) {
731
+ throw new ConfigurationError(
732
+ `control plane speaks ${JSON.stringify(served)}, this SDK speaks ` +
733
+ `${CONTRACT_VERSION}. Upgrade agent-flywheel.`,
734
+ );
735
+ }
736
+ if (typeof transport?.register !== "function") {
737
+ throw new ConfigurationError(
738
+ "the rollout transport cannot register this worker",
739
+ );
740
+ }
741
+ return transport.register(registrationSpec);
742
+ })()
743
+ .then(() => true)
744
+ .catch((err) => {
745
+ logger?.warn?.(
746
+ `[${pluginId}] rollouts are disabled for this run because worker ` +
747
+ `registration failed: ${err?.message ?? err}. Local capture is ` +
748
+ `unaffected.`,
749
+ );
750
+ return false;
751
+ });
752
+ return registration;
753
+ }
754
+
755
+ // Start now, without awaiting: host registration remains synchronous, while
756
+ // an incompatible or unreachable control plane is diagnosed at startup
757
+ // instead of waiting for a person's first session to reveal it. Every claim
758
+ // still awaits this exact cached promise.
759
+ void ensureRegistered();
760
+
761
+ // Both the attended hook and the unattended cron loop call this same claim
762
+ // method. A failed registration looks like an empty queue locally; it never
763
+ // reaches `/rollouts/claim`, and the warning above names the real cause once.
764
+ const registeredTransport = {
765
+ get leaseMs() { return transport.leaseMs; },
766
+ async claim(...args) {
767
+ if (!(await ensureRegistered())) {
768
+ return { rollouts: [], pollAfterMs: 5_000 };
769
+ }
770
+ return transport.claim(...args);
771
+ },
772
+ report: (...args) => transport.report(...args),
773
+ abandon: (...args) => transport.abandon(...args),
774
+ heartbeat: (...args) => transport.heartbeat(...args),
775
+ };
776
+
777
+ const driver = createRolloutDriver({
778
+ transport: registeredTransport,
779
+ enqueue: (injection) => enqueueFn.call(api.session?.workflow ?? api, injection),
780
+ logger,
781
+ executionIdentity,
782
+ });
783
+
784
+ // Failures are swallowed at the boundary: a telemetry plugin must never be
785
+ // able to break the agent it is measuring.
786
+ const safe = (fn) => (event, ctx) => {
787
+ Promise.resolve()
788
+ .then(() => fn(event, ctx))
789
+ .catch((err) => logger?.warn?.(
790
+ `[${pluginId}] rollout hook failed: ${err?.message ?? err}`));
791
+ };
792
+
793
+ api.on(SESSION_START, safe(driver.onSessionStart));
794
+ api.on(AGENT_END, safe(driver.onAgentEnd));
795
+ api.on(SESSION_END, safe(driver.onSessionEnd));
796
+ // This gate MUST return its decision. The observation-only wrapper above
797
+ // deliberately fire-and-forgets, which would discard a block result and
798
+ // let a revoked rollout reach the model anyway.
799
+ api.on(BEFORE_AGENT_RUN, async (event, ctx) => {
800
+ try {
801
+ return await driver.onBeforeAgentRun(event, ctx);
802
+ } catch (err) {
803
+ logger?.warn?.(
804
+ `[${pluginId}] rollout run gate failed: ${err?.message ?? err}`,
805
+ );
806
+ return undefined;
807
+ }
808
+ });
809
+ // Like the run gate, this policy hook must return synchronously/through its
810
+ // promise. It is registered only in training mode and becomes active only
811
+ // after the control plane has revoked this exact rollout lease.
812
+ api.on(BEFORE_TOOL_CALL, (event, ctx) => {
813
+ try {
814
+ return driver.onBeforeToolCall(event, ctx);
815
+ } catch (err) {
816
+ logger?.warn?.(
817
+ `[${pluginId}] rollout tool fence failed: ${err?.message ?? err}`,
818
+ );
819
+ return {
820
+ block: true,
821
+ blockReason: "PerceptEye could not verify the active rollout lease",
822
+ };
823
+ }
824
+ });
825
+
826
+ // ── RUNNING THE ROLLOUTS THIS LANE CLAIMS ─────────────────────────────
827
+ //
828
+ // Not behind its own switch. `PERCEPTEYE_AGENT_MODE` already answers "does
829
+ // this install drive?" -- training means it claims rollouts and runs them,
830
+ // production means it does not -- and a second flag was a second answer to
831
+ // that same question. The state it would have named, "claim rollouts but
832
+ // wait for a human to start each turn", is a distinction with no customer
833
+ // behind it: an operator who wants the agent left alone sets production.
834
+ //
835
+ // Reached only from training mode, because that is the only mode in which
836
+ // this function is called at all. Note what is NOT done here: the hooks
837
+ // above stay subscribed regardless, so if the cron seam is unavailable the
838
+ // plugin keeps claiming and delivering through injection rather than losing
839
+ // rollouts entirely. Starting turns is an ADDITION to that lane, not a
840
+ // replacement for it -- which is what makes the degradation path safe.
841
+ //
842
+ // The other half of that path lives in the runner: on a degraded start it
843
+ // STOPS POLLING, so the injection lane is left as the only claimer instead
844
+ // of competing with a loop that claims and hands back every 30 seconds.
845
+ // `capture` below is what can re-arm it, and only because both hooks it is
846
+ // bound to mean the host has just handed over a cron context.
847
+ let unattended = null;
848
+ {
849
+ // `getCron` lives on the gateway hook CONTEXT and nowhere else, so it has
850
+ // to be captured when the hook fires and resolved lazily afterwards -- the
851
+ // service may not be up at `gateway_start`, which is why this stores the
852
+ // context rather than the service. `cron_changed` carries the same
853
+ // context and is captured too, so a cron that arrives later is still
854
+ // reachable.
855
+ let gatewayCtx = null;
856
+ unattended = createUnattendedRunner({
857
+ transport: registeredTransport,
858
+ driver,
859
+ startTurn: cronTurnStarter(() => gatewayCtx?.getCron?.()),
860
+ logger,
861
+ });
862
+ const capture = (_event, ctx) => {
863
+ if (ctx?.getCron) gatewayCtx = ctx;
864
+ unattended.start();
865
+ };
866
+ api.on(GATEWAY_START, safe(capture));
867
+ api.on("cron_changed", safe(capture));
868
+ api.on(GATEWAY_STOP, safe(() => unattended.stop()));
869
+ logger?.info?.(
870
+ `[${pluginId}] training mode: this plugin claims rollouts and runs them, ` +
871
+ `starting its own turns in dedicated 'percepteye-rollout-*' sessions, ` +
872
+ `one at a time. Set PERCEPTEYE_AGENT_MODE=production to stop.`,
873
+ );
874
+ }
875
+
876
+ driver.unattended = unattended;
877
+ // Exposed for diagnostics and focused tests; invoking it performs the same
878
+ // single cached registration the first claim would perform.
879
+ driver.ensureRegistered = ensureRegistered;
880
+ return driver;
881
+ }
882
+
883
+ // Re-exported for the tests that pin these behaviours against this lane.
884
+ export { conversationAccessGranted, lastAssistantText };