@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/policy.js ADDED
@@ -0,0 +1,934 @@
1
+ /**
2
+ * PRODUCTION MODE: what the control plane says this agent should be running,
3
+ * read ONCE at startup, and applied.
4
+ *
5
+ * Two endpoints answer that question and they are not interchangeable:
6
+ *
7
+ * GET /policy/current WHICH MODEL. An OpenAI-compatible `endpoint`
8
+ * (base_url + model), the arm, and a
9
+ * `serving_generation` that changes when and only when
10
+ * the answer changes. Always 200; nulls included.
11
+ * GET /prompt/current WHICH SYSTEM PROMPT. The approved optimized text,
12
+ * or `{}` meaning KEEP THE PROMPT YOU HAVE. Never
13
+ * "use no prompt".
14
+ *
15
+ * ⚠ `policy.prompt` IS NOT PROMPT TEXT, and the name invites exactly that
16
+ * mistake. It is the control plane's own prompt-PIN identity snapshot --
17
+ * `{pins, digest, captured_at}`, a record of which of ITS OWN prompts were in
18
+ * force when the answer was computed. It has nothing to do with the
19
+ * customer's twin candidate, so `policy.prompt.digest` and this prompt's
20
+ * `sha256` are not comparable and are never compared here. The TEXT only ever
21
+ * comes from `/prompt/current`.
22
+ *
23
+ * ── THE PAIR, AND WHY BOTH HALVES ARE APPLIED ─────────────────────────────
24
+ *
25
+ * A bundle is a (prompt, model) PAIR: the optimized text was certified
26
+ * TOGETHER WITH a checkpoint, and `model_prompt_checksum` is the prompt
27
+ * identity stamped on that checkpoint. Applying one half alone runs the
28
+ * approved prompt over whatever model the customer's provider serves -- which
29
+ * is not the combination anybody evaluated, and this file used to say so in a
30
+ * warning on every start, because it could apply only the prompt.
31
+ *
32
+ * It can now apply both, so that warning is a warning only when one half is
33
+ * genuinely missing, and it names WHICH. See `pairNote`: that conditional is
34
+ * the whole point of the pair check.
35
+ *
36
+ * `agent_flywheel.current_prompt()` (Python) hands the text back
37
+ * and stops, deliberately -- it is a library inside an arbitrary Python
38
+ * process and has no idea where that process's prompt or model comes from.
39
+ * Here we ARE the host's prompt and model seams, so the check that docstring
40
+ * tells its caller to perform is ours to perform.
41
+ *
42
+ * ── RESOLVED ONCE, NOT POLLED ─────────────────────────────────────────────
43
+ *
44
+ * Both reads happen once, after registration, and the answer is held for the
45
+ * life of the process. `ttl_s` is a real cadence hint (300s, 60s while a
46
+ * candidate is in flight) and it is deliberately not honoured: a policy that
47
+ * changes under a running agent is the same confound the control plane
48
+ * refuses on -- half a process's turns served under one prompt and half under
49
+ * another, with nothing on either side recording where the seam was. The
50
+ * control plane's own remedy for a moved answer is a restart, which is why
51
+ * `serving_generation` supports equality and nothing else. Nothing here sits
52
+ * on the path of a turn.
53
+ *
54
+ * ── TRAINING MODE DOES NOT REACH THIS FILE ────────────────────────────────
55
+ *
56
+ * `registerServingPolicy` is called from the production branch of
57
+ * `index.js` only, the same structural split `mode.js` describes. A rollout is
58
+ * evidence about the agent as configured; applying an unproven prompt or model
59
+ * during one would change the behaviour being measured and attribute the
60
+ * difference to the gradient step.
61
+ *
62
+ * ── THE HOST SEAMS ────────────────────────────────────────────────────────
63
+ *
64
+ * Verified against the INSTALLED host, OpenClaw 2026.7.1-2 -- the version
65
+ * `package.json`'s `openclaw.compat.pluginApi` declares -- not against its
66
+ * documentation:
67
+ *
68
+ * `before_prompt_build` receives `{prompt, messages}` and its RESULT is what
69
+ * the host reads. `systemPrompt` is a RETURN FIELD, not a mutable field on
70
+ * the event: `resolvePromptBuildSystemPrompt`
71
+ * (agent-harness-runtime-827dyFNd.js:193-197) takes
72
+ * `promptBuildResult.systemPrompt` in preference to `developerInstructions`,
73
+ * which is the host's fully assembled system prompt. Mutating the event does
74
+ * NOTHING. Returning replaces the whole prompt, per attempt.
75
+ *
76
+ * It is a PROMPT-INJECTION hook (`PROMPT_INJECTION_HOOK_NAMES`,
77
+ * command-registration-tKF3dsKu.js:162-167) and the gate is OPT-OUT: the
78
+ * registry blocks it only on an explicit
79
+ * `plugins.entries.<id>.hooks.allowPromptInjection === false`
80
+ * (registry-B8eQDFB4.js:4206-4214). It is NOT in `CONVERSATION_HOOK_NAMES`,
81
+ * so the opt-IN that `agent_end` needs does not apply to it.
82
+ *
83
+ * `before_model_resolve` receives `{prompt, attachments?}` and returns
84
+ * `{providerOverride, modelOverride}`, honoured at
85
+ * embedded-agent-DGUuxGR2.js:1660-1667. It IS in `CONVERSATION_HOOK_NAMES`
86
+ * (command-registration-tKF3dsKu.js:170-178), so unlike the prompt hook it
87
+ * needs the operator's `plugins.entries.<id>.hooks.allowConversationAccess
88
+ * = true` -- the same opt-in this plugin already asks for to capture
89
+ * answers. Without it the host refuses the subscription with a bare return
90
+ * (registry-B8eQDFB4.js:4225-4235) and `api.on` reports nothing, so the
91
+ * operator has to be told by us, once.
92
+ *
93
+ * THE BASE URL comes from neither of those. `providerOverride` names a
94
+ * provider; a registered provider's `normalizeTransport` is what supplies
95
+ * `{api, baseUrl}` (types-DaHgOqFX.d.ts:11113-11116, applied at
96
+ * provider-runtime-CLQOjLJ6.js:126-149). The two go together: the override
97
+ * is what makes `ctx.provider` ours, and ours is the only value we may act
98
+ * on -- see `normalizeTransport` below for the footgun that makes that gate
99
+ * mandatory rather than tidy. So the REGISTRATION is part of whether the
100
+ * model half may fire at all, not a step that follows that decision: see
101
+ * `openModelSeam`, which asks the whole question once.
102
+ *
103
+ * BOTH hook contexts carry `agentId` and `sessionKey`
104
+ * (lifecycle-hook-helpers-BwL6869q.js:9-34,
105
+ * embedded-agent-DGUuxGR2.js:2292-2303), which is what `scope.js` reads to
106
+ * keep this agent's approved answer off every OTHER agent, subagent and
107
+ * cron turn in the same process.
108
+ *
109
+ * FEATURE-DETECTED, NEVER ASSUMED. The host source available to read is
110
+ * 2026.4.3 and it agrees on all of the above, but a patch release may close
111
+ * either seam at any time and `api.on` reports nothing either way. So: the
112
+ * hooks are subscribed, they return values, and if the host never calls them
113
+ * or ignores what they return, the agent runs exactly as it does today. The
114
+ * cost of a closed seam is the improvement and nothing else.
115
+ */
116
+ import { resolveServedAgent, scopeDecision } from "./scope.js";
117
+
118
+ /** The host hook whose RESULT carries the system prompt. */
119
+ export const PROMPT_HOOK_NAME = "before_prompt_build";
120
+
121
+ /** The host hook whose RESULT carries the provider and model. */
122
+ export const MODEL_HOOK_NAME = "before_model_resolve";
123
+
124
+ /**
125
+ * The provider this plugin registers, and the ONLY id it will act on.
126
+ *
127
+ * Declared in `openclaw.plugin.json` as well: a plugin's provider surface is
128
+ * live only when the manifest's `providers` array is non-empty AND the plugin
129
+ * is activated (providers-y2Lns8fh.js:31-35,92-103). Registering one without
130
+ * declaring it is a registration nothing consults.
131
+ */
132
+ export const PROVIDER_ID = "percepteye-flywheel";
133
+
134
+ /**
135
+ * The transport family the served endpoint speaks.
136
+ *
137
+ * The control plane serves an OpenAI-compatible completions API, and
138
+ * `openai-completions` is the host's own vocabulary for it
139
+ * (config-3MmNBWgm.js:22,46). `GET /policy/current` answers with a `base_url`
140
+ * whose trailing `/chat/completions` is ALREADY STRIPPED, because an OpenAI
141
+ * client appends that suffix itself -- so it is used verbatim and never
142
+ * re-appended.
143
+ */
144
+ export const PROVIDER_TRANSPORT_API = "openai-completions";
145
+
146
+ /**
147
+ * The delivery states that may serve.
148
+ *
149
+ * The control plane's own boundary, not a second one: it serves a bundle only
150
+ * in these two states, and its prompt export enforces the same set.
151
+ * Re-asserting it here is not a duplicate gate -- it is the only thing
152
+ * standing between a control plane that one day widens the filter and a
153
+ * customer's agent silently running an unapproved prompt.
154
+ */
155
+ export const SERVABLE_PROMPT_STATUSES = new Set(["approved", "delivered"]);
156
+
157
+ /**
158
+ * Prefixes that mean STORED ARTIFACT, not sampling address.
159
+ *
160
+ * The control plane already refuses to hand one of these out as a `model`,
161
+ * for exactly the reason this refuses to send one:
162
+ * `model="gs://bucket/run/iter_0"` 404s on every completion. Re-asserted here
163
+ * on the same argument as the status set above.
164
+ */
165
+ export const ARCHIVE_REF_PREFIXES = ["gs://", "s3://", "file://", "/"];
166
+
167
+ /** Said the same way wherever it is said. */
168
+ export const PROMPT_OFF_REASON =
169
+ "prompt application is switched off (PERCEPTEYE_APPLY_PROMPT / applyPrompt)";
170
+
171
+ /** Said the same way wherever it is said. */
172
+ export const MODEL_OFF_REASON =
173
+ "model application is switched off (PERCEPTEYE_APPLY_MODEL / applyModel)";
174
+
175
+ /** Why the model half cannot fire on this host at all. One phrasing, one place. */
176
+ export const modelHookBlockedReason = (pluginId) =>
177
+ `${MODEL_HOOK_NAME} is a conversation hook and this host drops it for ` +
178
+ `non-bundled plugins unless you set plugins.entries.${pluginId}.hooks.` +
179
+ "allowConversationAccess=true -- the same setting that lets this plugin " +
180
+ "capture your agent's answers";
181
+
182
+ /**
183
+ * Why the model half cannot fire when this host has no provider surface.
184
+ *
185
+ * THE PROVIDER IS THE ONLY PRODUCER OF THE BASE URL. `handleModelResolve`
186
+ * returns `providerOverride: PROVIDER_ID`, and the only thing that turns that
187
+ * id into `{api, baseUrl}` is the registered provider's `normalizeTransport`.
188
+ * Returning the override on a host that never took the registration names a
189
+ * provider the host does not know: the resolve fails outright, or the served
190
+ * model name is handed to the customer's OWN provider, which does not serve
191
+ * it. Both break a working agent to deliver an improvement that cannot
192
+ * arrive, which is the one thing this file exists not to do.
193
+ */
194
+ export const NO_PROVIDER_SURFACE_REASON =
195
+ `this host exposes no provider registration (api.registerProvider is ` +
196
+ `missing), and a registered provider is the only thing that can supply the ` +
197
+ `base URL of the approved endpoint -- so naming '${PROVIDER_ID}' in ` +
198
+ `${MODEL_HOOK_NAME} would point this agent at a provider the host does not ` +
199
+ "know";
200
+
201
+ /** Why the model half cannot fire when the host REFUSED the registration. */
202
+ export const providerRefusedReason = (detail) =>
203
+ `registering the provider '${PROVIDER_ID}' failed (${detail}), and a ` +
204
+ "registered provider is the only thing that can supply the base URL of the " +
205
+ `approved endpoint -- so naming it in ${MODEL_HOOK_NAME} would point this ` +
206
+ "agent at a provider the host does not know";
207
+
208
+ /**
209
+ * The refusal that means NOTHING IS WRONG -- the answer has not arrived yet.
210
+ *
211
+ * `refusal` and `modelRefusal` are true at every moment, so before the startup
212
+ * read they have to say something, and this is it. It is exported because it
213
+ * is also the ONE test for "this half can still apply": every other value
214
+ * either half can hold at construction is a settled refusal. `index.js` asks
215
+ * that question rather than re-deriving the predicate from a subset of its
216
+ * inputs, which is how its startup line came to promise a model half that a
217
+ * refused provider registration had already ruled out.
218
+ */
219
+ export const PENDING_ANSWER_REASON = "the control plane has not been asked yet";
220
+
221
+ /** Why nothing is read or applied when no key is configured. */
222
+ export const NO_KEY_REASON =
223
+ "no API key is configured, so the control plane is never asked and no hook " +
224
+ "is subscribed (set PERCEPTEYE_API_KEY, or plugins.entries.<id>.config." +
225
+ "apiKey)";
226
+
227
+ const str = (v) => (typeof v === "string" ? v.trim() : "");
228
+
229
+ /** A checksum, shortened for a log line. Never a claim that it is complete. */
230
+ const short = (v) => (v ? `${v.slice(0, 12)}…` : "none");
231
+
232
+ const isArchiveRef = (value) =>
233
+ ARCHIVE_REF_PREFIXES.some((p) => value.startsWith(p));
234
+
235
+ /**
236
+ * MAY THIS PROMPT BE APPLIED, and if not, WHY NOT.
237
+ *
238
+ * Pure, and separate from everything that logs or subscribes, because it is
239
+ * the decision worth testing exhaustively. Each refusal names ITS OWN cause;
240
+ * one message covering four different failures would tell an operator to go
241
+ * looking in the wrong place three times out of four.
242
+ *
243
+ * @param {object} prompt the `/prompt/current` body, possibly `{}`
244
+ * @returns {{apply: boolean, text: string|null, reason: string,
245
+ * checksum: string|null}}
246
+ */
247
+ export function promptDecision(prompt) {
248
+ const p = prompt && typeof prompt === "object" ? prompt : {};
249
+ const text = typeof p.text === "string" ? p.text : "";
250
+ const checksum = str(p.model_prompt_checksum) || null;
251
+
252
+ // `{}` IS AN ANSWER, and it is "keep what you have". No product declares
253
+ // this agent, no twin optimizes that product, nothing has passed the gate,
254
+ // or the control plane could not answer -- and every one of those means the
255
+ // host's own prompt stands. It never means "run with no prompt".
256
+ if (!text.trim()) {
257
+ return {
258
+ apply: false, text: null, checksum,
259
+ reason: "the control plane is serving no approved prompt for this " +
260
+ "agent (empty answer); the prompt this host assembles stands",
261
+ };
262
+ }
263
+
264
+ // THE BASELINE MOVED UNDER IT. The customer re-intook the twin with a
265
+ // changed baseline after this bundle was approved, so the approved text was
266
+ // shaped against a prompt they no longer ship. The deliverable still
267
+ // stands as a record; applying it is a different question, and the Python
268
+ // SDK names this as the first thing a caller must check before applying.
269
+ // Re-certification is manual, so this does not clear on its own.
270
+ if (p.stale_baseline === true) {
271
+ return {
272
+ apply: false, text: null, checksum,
273
+ reason: "stale_baseline=true: this agent's own baseline prompt changed " +
274
+ "after the pair was certified, so the approved text was shaped " +
275
+ "against a prompt this agent no longer ships. Re-certification is " +
276
+ "manual -- ask the control plane to re-certify the pair",
277
+ };
278
+ }
279
+
280
+ const status = str(p.status);
281
+ if (!SERVABLE_PROMPT_STATUSES.has(status)) {
282
+ return {
283
+ apply: false, text: null, checksum,
284
+ reason: `the served bundle's status is ${JSON.stringify(status)}, not ` +
285
+ `one of ${[...SERVABLE_PROMPT_STATUSES].join("/")}; only an operator-` +
286
+ "approved bundle is applied",
287
+ };
288
+ }
289
+
290
+ // THE PAIR, AS FAR AS THIS SIDE CAN CHECK IT. `model_prompt_checksum` is
291
+ // the prompt identity STAMPED ON THE CHECKPOINT at certification;
292
+ // `sha256` is the optimized prompt's own identity. Delivery assembly on the
293
+ // control plane refuses with a 409 unless the two are equal, so a served
294
+ // bundle where they differ is not the validated pair, whatever its status
295
+ // says. That is an inconsistency in the deliverable, not a policy call, so
296
+ // it refuses.
297
+ //
298
+ // Checked only when BOTH are present: a blank one means we could not look,
299
+ // which is the "cannot prove" case the pair note names, not a proven
300
+ // mismatch.
301
+ const sha = str(p.sha256);
302
+ if (sha && checksum && sha !== checksum) {
303
+ return {
304
+ apply: false, text: null, checksum,
305
+ reason: `the bundle disagrees with itself: the prompt's sha256 is ` +
306
+ `${short(sha)} but the checkpoint it was certified against is ` +
307
+ `stamped ${short(checksum)}. Assembly refuses that pair, so this ` +
308
+ "answer is not the thing that was approved",
309
+ };
310
+ }
311
+
312
+ return { apply: true, text, checksum, reason: "" };
313
+ }
314
+
315
+ /**
316
+ * MAY THIS MODEL BE APPLIED, and if not, WHY NOT.
317
+ *
318
+ * The model half's counterpart to `promptDecision`, and deliberately short:
319
+ * every POLICY question -- has a person moved this agent onto the candidate,
320
+ * did certification refuse it, is it deployed anywhere servable -- was already
321
+ * answered by the control plane before it filled `endpoint` in at all, and all
322
+ * three fail closed to the champion. An agent that never left the champion arm
323
+ * is served the champion, and that is an answer to apply, not one to
324
+ * second-guess. What is left for this side is whether the ADDRESS is one an
325
+ * agent can actually call.
326
+ *
327
+ * @param {object} policy the `/policy/current` body, possibly `{}`
328
+ * @returns {{apply: boolean, baseUrl: string|null, model: string|null,
329
+ * reason: string}}
330
+ */
331
+ export function modelDecision(policy) {
332
+ const p = policy && typeof policy === "object" ? policy : {};
333
+ const ep = p.endpoint && typeof p.endpoint === "object" ? p.endpoint : {};
334
+ const baseUrl = str(ep.base_url);
335
+ const model = str(ep.model);
336
+
337
+ if (!baseUrl || !model) {
338
+ return {
339
+ apply: false, baseUrl: null, model: null,
340
+ reason: "the control plane is serving no endpoint for this agent " +
341
+ "(nothing is deployed for it, or no policy resolved), so there is " +
342
+ "nothing to point it at",
343
+ };
344
+ }
345
+
346
+ // AN ARCHIVE URI IS NOT A MODEL NAME. `is_servable` refuses this on the
347
+ // server for exactly this reason -- a bucket path in `model` 404s on every
348
+ // completion -- so an answer carrying one is not a policy an agent can run,
349
+ // whatever else it says.
350
+ if (isArchiveRef(model)) {
351
+ return {
352
+ apply: false, baseUrl: null, model: null,
353
+ reason: `the served model ${JSON.stringify(model)} is a stored-artifact ` +
354
+ "reference, not a name an endpoint can serve; that address would 404 " +
355
+ "on every completion",
356
+ };
357
+ }
358
+
359
+ return { apply: true, baseUrl, model, reason: "" };
360
+ }
361
+
362
+ /**
363
+ * The one line an operator needs to see what the control plane WOULD serve.
364
+ *
365
+ * Every field here has a reader: `arm` and `reason` say which policy this
366
+ * agent is on and why, `serving_generation` is the equality token a fleet
367
+ * cycles a replica on, and `endpoint` is the address the model half points at.
368
+ */
369
+ export function servedPolicySummary(policy) {
370
+ const p = policy && typeof policy === "object" ? policy : {};
371
+ const ep = p.endpoint && typeof p.endpoint === "object" ? p.endpoint : {};
372
+ const base = str(ep.base_url);
373
+ const model = str(ep.model);
374
+ return (
375
+ `arm=${str(p.arm) || "unknown"} ` +
376
+ `serving_generation=${str(p.serving_generation) || "none"} ` +
377
+ `candidate_available=${p.candidate_available === true} ` +
378
+ `endpoint=${base || "none"} model=${model || "none"}` +
379
+ (str(p.reason) ? ` (${p.reason})` : "")
380
+ );
381
+ }
382
+
383
+ /**
384
+ * THE PAIR CHECK, as a sentence -- or null when there is nothing to say.
385
+ *
386
+ * This is why the two halves live in one file. The bundle is a (prompt, model)
387
+ * pair, so:
388
+ *
389
+ * both applied the prompt is running on the checkpoint it was certified
390
+ * with. An ordinary confirmation; nothing is wrong.
391
+ * one applied what runs is HALF of a validated combination, and an
392
+ * operator who is not told will read a behaviour change as
393
+ * the certified improvement. A warning, naming WHICH half is
394
+ * missing and why -- "one half is missing" without saying
395
+ * which sends them to look in the wrong place half the time.
396
+ * neither each half has already logged its own cause; there is no
397
+ * pair to say anything about.
398
+ *
399
+ * @returns {{level: "info"|"warn", text: string}|null}
400
+ */
401
+ export function pairNote({
402
+ promptApplied, modelApplied, promptRefusal, modelRefusal, checksum,
403
+ }) {
404
+ if (promptApplied && modelApplied) {
405
+ return {
406
+ level: "info",
407
+ text: "the pair is intact: the approved prompt is running on the " +
408
+ `checkpoint it was certified with (stamped ${short(checksum ?? "")}), ` +
409
+ "which is the combination that was evaluated",
410
+ };
411
+ }
412
+ if (promptApplied) {
413
+ return {
414
+ level: "warn",
415
+ text: "HALF THE PAIR: the approved prompt is applied but the MODEL half " +
416
+ `is not -- ${modelRefusal || "no reason was recorded"}. The prompt ` +
417
+ `was certified as a pair with the checkpoint stamped ` +
418
+ `${short(checksum ?? "")}, so it runs over whatever model your ` +
419
+ "provider serves, which is not the combination that was evaluated",
420
+ };
421
+ }
422
+ if (modelApplied) {
423
+ return {
424
+ level: "warn",
425
+ text: "HALF THE PAIR: the approved model is applied but the PROMPT half " +
426
+ `is not -- ${promptRefusal || "no reason was recorded"}. The ` +
427
+ "checkpoint was certified together with an optimized prompt, so it " +
428
+ "runs under whatever prompt your host assembles, which is not the " +
429
+ "combination that was evaluated",
430
+ };
431
+ }
432
+ return null;
433
+ }
434
+
435
+ /**
436
+ * Hold the control plane's answer, and hand both halves to the host.
437
+ *
438
+ * The hooks are subscribed SYNCHRONOUSLY at plugin load while the answer
439
+ * arrives later, so the handlers return nothing until `resolve` has run. That
440
+ * ordering is the fail-open one: the first turns of a freshly started gateway
441
+ * run on the host's own prompt and model, exactly as they do today, rather
442
+ * than waiting on a network call between a user and their answer.
443
+ *
444
+ * @param {object} opts
445
+ * @param {string} opts.pluginId
446
+ * @param {object} [opts.logger]
447
+ * @param {boolean} [opts.applyPrompt] the prompt kill switch, already resolved
448
+ * @param {boolean} [opts.applyModel] the model kill switch, already resolved
449
+ * @param {object} [opts.servedAgent] from `resolveServedAgent`
450
+ * @param {string|null} [opts.modelBlocked] why the model half cannot fire on
451
+ * this host AT ALL -- the kill switch, a missing conversation opt-in, or a
452
+ * provider registration the host does not offer or refused. Decided once by
453
+ * `registerServingPolicy`, which is the only place that knows all three.
454
+ * @param {string|null} [opts.unwired] why no seam was wired, when none was
455
+ */
456
+ export function createPolicySource({
457
+ pluginId, logger = null, applyPrompt = true, applyModel = true,
458
+ servedAgent = resolveServedAgent({}), modelBlocked = null,
459
+ unwired = null,
460
+ }) {
461
+ let policy = {};
462
+ let prompt = {};
463
+ let applied = null;
464
+ let appliedModel = null;
465
+
466
+ // NOTHING CAN BE APPLIED TO ANY RUN when the agent scope is unresolvable --
467
+ // several agents on this host and none of them named -- because
468
+ // `scopeDecision` refuses every one of them. It is known at construction,
469
+ // so it belongs here beside the other never-going-to-happen causes rather
470
+ // than only inside the handlers: this source used to take the control
471
+ // plane's answer, set BOTH `applied` and `appliedModel` from it, clear both
472
+ // refusals, and then announce "the pair is intact" for a pair that ran on
473
+ // nothing.
474
+ const scopeUnresolved = servedAgent?.id
475
+ ? null
476
+ : servedAgent?.reason || "no agent is resolved";
477
+
478
+ // TRUE AT EVERY MOMENT, including before the read and when the read never
479
+ // happens (no key, no transport). A `refusal` that is null while
480
+ // `appliedPrompt` is also null cannot be told apart from a source that
481
+ // applied nothing for a reason, and this field exists to be read.
482
+ //
483
+ // The switched-off value is THE CONSTANT, not a second phrasing of it: an
484
+ // operator must read the same sentence here and in the startup log, and a
485
+ // comment saying "keep these in sync" is not a mechanism.
486
+ let refusal = !applyPrompt ? PROMPT_OFF_REASON
487
+ : unwired ?? scopeUnresolved ?? PENDING_ANSWER_REASON;
488
+ let refusedModel = !applyModel ? MODEL_OFF_REASON
489
+ : modelBlocked ?? unwired ?? scopeUnresolved ?? PENDING_ANSWER_REASON;
490
+ let resolved = false;
491
+ let announcedPrompt = false;
492
+ let announcedModel = false;
493
+
494
+ /** The scope gate, asked identically by both halves. */
495
+ const inScope = (ctx) => scopeDecision(ctx, servedAgent);
496
+
497
+ // EVERY SCOPE REFUSAL HAS A READER NOW. `scopeDecision` builds a specific
498
+ // sentence for each of its cases -- a subagent session, a cron turn, the
499
+ // wrong host agent, no hook context, neither identifier -- and until this
500
+ // existed every one of them was computed on every turn and dropped, leaving
501
+ // an operator with a startup log promising both halves, no behaviour change,
502
+ // and nothing anywhere naming the cause.
503
+ //
504
+ // Once per DISTINCT reason, not per turn: this sits in front of every turn
505
+ // the process serves. The set is bounded by the six sentences and the host's
506
+ // own agent ids.
507
+ const saidScope = new Set();
508
+ const sayScope = (half, reason) => {
509
+ const line = `${half}: ${reason}`;
510
+ if (saidScope.has(line)) return;
511
+ saidScope.add(line);
512
+ logger?.info?.(
513
+ `[${pluginId}] the approved ${half} was NOT applied to this run -- ` +
514
+ `${reason}.`);
515
+ };
516
+
517
+ const source = {
518
+ /** The `/policy/current` body. `{}` until resolved, or on any failure. */
519
+ get policy() { return policy; },
520
+ /** The `/prompt/current` body. `{}` when not read or not readable. */
521
+ get prompt() { return prompt; },
522
+ /** The text handed to the host, or null when none is being applied. */
523
+ get appliedPrompt() { return applied; },
524
+ /** `{provider, model, baseUrl}` handed to the host, or null when none is. */
525
+ get appliedModel() { return appliedModel; },
526
+ /** Why no prompt is being applied, or null when one is. */
527
+ get refusal() { return refusal; },
528
+ /** Why no model is being applied, or null when one is. */
529
+ get modelRefusal() { return refusedModel; },
530
+ /** Which host agent this install's answer is for. `id` null when ambiguous. */
531
+ get servedAgent() { return servedAgent; },
532
+ /**
533
+ * Whether the startup read has finished.
534
+ *
535
+ * ONE read or TWO: `/policy/current` always, `/prompt/current` only when
536
+ * prompt application is on AND the agent scope resolved -- pulling the
537
+ * customer's prompt text over the wire to discard it is a cost with no
538
+ * reader. Stays false forever when there is no transport to read through,
539
+ * because no read was ever attempted.
540
+ */
541
+ get resolved() { return resolved; },
542
+
543
+ /**
544
+ * Read both endpoints, decide, and say so once. NEVER REJECTS.
545
+ *
546
+ * A control plane that is down costs the improvement and nothing else:
547
+ * `policy` and `prompt` stay `{}`, nothing is applied, and the hooks go on
548
+ * returning nothing.
549
+ */
550
+ async resolve(transport) {
551
+ if (!transport) return source;
552
+ try {
553
+ policy = (await transport.policyCurrent()) || {};
554
+ } catch {
555
+ policy = {};
556
+ }
557
+ logger?.info?.(`[${pluginId}] ${servedPolicySummary(policy)}`);
558
+
559
+ // WHOSE RUN? -- and when the answer is "we cannot tell", NOTHING is
560
+ // applied to any run, so neither half's refusal may be overwritten by
561
+ // the payload's answer and the customer's prompt text is not fetched to
562
+ // be discarded. `registerServingPolicy` has already warned, naming the
563
+ // agents and the remedy; `announcePair` stays silent because there is no
564
+ // pair, which is what stops "the pair is intact" being logged for a
565
+ // bundle that reaches no turn.
566
+ if (scopeUnresolved) {
567
+ resolved = true;
568
+ source.announcePair();
569
+ return source;
570
+ }
571
+
572
+ // ── THE MODEL HALF ────────────────────────────────────────────────
573
+ // The kill switch, a missing conversation opt-in and a provider the host
574
+ // will not register are decided before any read and must not be
575
+ // overwritten by the payload's answer: an operator who switched the
576
+ // model half off is not owed a sentence about an endpoint they asked not
577
+ // to use, and an endpoint that has no provider to reach it is not
578
+ // applied however good the answer is.
579
+ if (applyModel && !modelBlocked) {
580
+ const decision = modelDecision(policy);
581
+ if (decision.apply) {
582
+ appliedModel = {
583
+ provider: PROVIDER_ID,
584
+ model: decision.model,
585
+ baseUrl: decision.baseUrl,
586
+ };
587
+ refusedModel = null;
588
+ } else {
589
+ appliedModel = null;
590
+ refusedModel = decision.reason;
591
+ }
592
+ logger?.info?.(appliedModel
593
+ ? `[${pluginId}] applying the approved model ` +
594
+ `'${appliedModel.model}' at ${appliedModel.baseUrl} via ` +
595
+ `${MODEL_HOOK_NAME}, through the provider '${PROVIDER_ID}' this ` +
596
+ "plugin registers. If that endpoint needs a credential it comes " +
597
+ `from your host's own models.providers.${PROVIDER_ID} entry. Set ` +
598
+ "PERCEPTEYE_APPLY_MODEL=0 to stop applying it."
599
+ : `[${pluginId}] the approved model is NOT being applied: ` +
600
+ `${refusedModel}.`);
601
+ }
602
+
603
+ if (!applyPrompt) {
604
+ // NOT FETCHED, not fetched-and-dropped. `/prompt/current` carries the
605
+ // customer's own prompt text; pulling it over the wire to throw it
606
+ // away is a cost with no reader. `refusal` already says so.
607
+ resolved = true;
608
+ source.announcePair();
609
+ return source;
610
+ }
611
+
612
+ // ── THE PROMPT HALF ───────────────────────────────────────────────
613
+ try {
614
+ prompt = (await transport.promptCurrent()) || {};
615
+ } catch {
616
+ prompt = {};
617
+ }
618
+ const decision = promptDecision(prompt);
619
+ resolved = true;
620
+ if (!decision.apply) {
621
+ applied = null;
622
+ refusal = decision.reason;
623
+ logger?.info?.(
624
+ `[${pluginId}] the approved system prompt is NOT being applied: ` +
625
+ `${refusal}.`);
626
+ } else {
627
+ applied = decision.text;
628
+ refusal = null;
629
+ logger?.info?.(
630
+ `[${pluginId}] applying the approved system prompt ` +
631
+ `'${str(prompt.name) || "unnamed"}' v${prompt.version ?? "?"} ` +
632
+ `(${applied.length} chars, status=${str(prompt.status)}) via ` +
633
+ `${PROMPT_HOOK_NAME}. Set PERCEPTEYE_APPLY_PROMPT=0 to stop ` +
634
+ "applying it.");
635
+ }
636
+ source.announcePair();
637
+ return source;
638
+ },
639
+
640
+ /** The pair sentence, at the level `pairNote` chose. */
641
+ announcePair() {
642
+ const note = pairNote({
643
+ promptApplied: Boolean(applied),
644
+ modelApplied: Boolean(appliedModel),
645
+ promptRefusal: refusal,
646
+ modelRefusal: refusedModel,
647
+ checksum: str(prompt?.model_prompt_checksum) || null,
648
+ });
649
+ if (!note) return;
650
+ const write = note.level === "warn" ? logger?.warn : logger?.info;
651
+ write?.call(logger, `[${pluginId}] ${note.text}.`);
652
+ },
653
+
654
+ /**
655
+ * The `before_prompt_build` body. Returns the host's result object, or
656
+ * `undefined` to change nothing.
657
+ *
658
+ * `undefined` rather than `{systemPrompt: undefined}` because the host
659
+ * merges results across plugins with `firstDefined`, and a present-but-
660
+ * undefined field is a claim on that slot we have no right to make.
661
+ *
662
+ * READS THE CONTEXT, and must. WHAT to apply was settled once at startup,
663
+ * but WHETHER IT APPLIES TO THIS RUN cannot be: the host runs this hook
664
+ * for every agent, every subagent and every cron turn in the process, and
665
+ * this handler used to take NO ARGUMENTS and answer all of them -- one
666
+ * operator's approval for one agent became a process-wide prompt swap.
667
+ * See `scope.js`.
668
+ */
669
+ handle(event, ctx) {
670
+ if (!applied) return undefined;
671
+ const scope = inScope(ctx);
672
+ if (!scope.apply) {
673
+ sayScope("system prompt", scope.reason);
674
+ return undefined;
675
+ }
676
+ if (!announcedPrompt) {
677
+ announcedPrompt = true;
678
+ // FEATURE DETECTION, reported once. This is the only evidence
679
+ // available that the host called us at all -- a version that dropped
680
+ // the hook would simply never reach this line, and the operator would
681
+ // otherwise see a startup log promising an applied prompt with
682
+ // nothing behind it. It says CALLED, not HONOURED: whether the return
683
+ // is read is the host's decision and we cannot observe it.
684
+ logger?.info?.(
685
+ `[${pluginId}] this host called ${PROMPT_HOOK_NAME} for agent ` +
686
+ `'${servedAgent.id}'; the approved prompt was returned to it. ` +
687
+ "Whether the host applies the return is the host's decision.");
688
+ }
689
+ return { systemPrompt: applied };
690
+ },
691
+
692
+ /**
693
+ * The `before_model_resolve` body: `{providerOverride, modelOverride}`, or
694
+ * `undefined` to change nothing.
695
+ *
696
+ * `providerOverride` is what makes `ctx.provider` ours on the transport
697
+ * seam below; without it `normalizeTransport` is asked only about somebody
698
+ * else's provider and correctly declines. The two are one change.
699
+ *
700
+ * Scoped exactly as the prompt half is, by the same predicate: a subagent
701
+ * or cron turn repointed at a trained checkpoint is the same defect as one
702
+ * given somebody else's prompt.
703
+ */
704
+ handleModelResolve(event, ctx) {
705
+ if (!appliedModel) return undefined;
706
+ const scope = inScope(ctx);
707
+ if (!scope.apply) {
708
+ sayScope("model", scope.reason);
709
+ return undefined;
710
+ }
711
+ if (!announcedModel) {
712
+ announcedModel = true;
713
+ logger?.info?.(
714
+ `[${pluginId}] this host called ${MODEL_HOOK_NAME} for agent ` +
715
+ `'${servedAgent.id}'; the approved model was returned to it. ` +
716
+ "Whether the host applies the return is the host's decision.");
717
+ }
718
+ return {
719
+ providerOverride: appliedModel.provider,
720
+ modelOverride: appliedModel.model,
721
+ };
722
+ },
723
+
724
+ /**
725
+ * The provider's transport seam: `{api, baseUrl}` for OUR provider only.
726
+ *
727
+ * ⚠ THE FOOTGUN, and the reason the guard is the first thing here rather
728
+ * than a tidy detail. `normalizeProviderTransportWithPlugin`
729
+ * (provider-runtime-CLQOjLJ6.js:153-165) tries the MATCHED provider's
730
+ * `normalizeTransport` and then, if that changed nothing, LOOPS OVER EVERY
731
+ * OTHER registered provider plugin and takes the first return that changes
732
+ * `api` or `baseUrl`. So a `normalizeTransport` that answered
733
+ * unconditionally would redirect the customer's Anthropic or OpenAI
734
+ * traffic to our gateway -- silently, on a turn we were never asked about.
735
+ *
736
+ * `ctx.provider` is the only thing that says whose transport is being
737
+ * assembled, so it is the only thing this answers on. Everything else gets
738
+ * `undefined`, which the host reads as "no change".
739
+ *
740
+ * THE CONTEXT CARRIES NO AGENT (`ProviderNormalizeTransportContext` is
741
+ * `{config?, workspaceDir?, provider, modelId?, api?, baseUrl?}`), so the
742
+ * per-agent scope cannot be asked here. It does not need to be: this
743
+ * provider becomes the current one only because `handleModelResolve`
744
+ * named it, and that IS scoped. The one other way to reach it is an
745
+ * operator writing `models.providers.percepteye-flywheel` into their own
746
+ * config and pointing an agent at it by hand -- which is that operator
747
+ * deliberately using this endpoint, and answering them is correct.
748
+ */
749
+ normalizeTransport(ctx) {
750
+ if (!appliedModel) return undefined;
751
+ if (str(ctx?.provider) !== PROVIDER_ID) return undefined;
752
+ return {
753
+ api: PROVIDER_TRANSPORT_API,
754
+ // VERBATIM. `/policy/current` already stripped a trailing
755
+ // `/chat/completions`; re-appending it produces a URL nothing serves.
756
+ baseUrl: appliedModel.baseUrl,
757
+ };
758
+ },
759
+ };
760
+ return source;
761
+ }
762
+
763
+ /**
764
+ * Subscribe both seams and start the one-shot read. Production only.
765
+ *
766
+ * Returns the source whether or not anything was wired, so a caller always
767
+ * has something to read -- an inert source answers `{}` / null, and says why.
768
+ *
769
+ * @param {object} api the OpenClaw plugin api
770
+ * @param {object} opts
771
+ * @param {object} opts.transport an AttachTransport, or null with no API key
772
+ * @param {object} [opts.hostConfig] `api.config`, for the agent scope
773
+ * @param {boolean} [opts.conversationAccess] whether this host will deliver
774
+ * conversation hooks to this plugin
775
+ * @param {Promise|null} opts.after resolve only once this settles (the
776
+ * registration chain), so three control-plane calls do not race at startup
777
+ */
778
+ export function registerServingPolicy(api, {
779
+ config, pluginId, logger = null, transport = null, after = null,
780
+ hostConfig = api?.config, conversationAccess = true,
781
+ }) {
782
+ const applyPrompt = config?.applyPromptEnabled !== false;
783
+ const applyModel = config?.applyModelEnabled !== false;
784
+ const servedAgent = resolveServedAgent({
785
+ agentId: config?.agentId, hostConfig,
786
+ });
787
+
788
+ // WHY NOTHING WILL BE WIRED. "No key" is the ordinary first install and the
789
+ // consent boundary, and it is the only cause reachable here: `register()` in
790
+ // `index.js` returns before this is called when the host exposes no `api.on`,
791
+ // so a second branch naming that would be a refusal no host can produce.
792
+ const unwired = transport ? null : NO_KEY_REASON;
793
+
794
+ // Assigned immediately below. The provider's `normalizeTransport` closes
795
+ // over it and is called by the HOST, long after this function returns.
796
+ let source = null;
797
+
798
+ /**
799
+ * CAN THE MODEL HALF FIRE ON THIS HOST AT ALL, and if not, why -- asked
800
+ * ONCE, here, because this is the only place that knows every input to it.
801
+ *
802
+ * Registering the provider IS part of the question, not a step that follows
803
+ * it. `handleModelResolve` returns `providerOverride: PROVIDER_ID` and the
804
+ * registered provider is the ONLY producer of the base URL that override
805
+ * consumes, so a host that has no provider surface, or that refuses the
806
+ * registration, cannot serve the approved model however good the control
807
+ * plane's answer is. This used to swallow the refusal, subscribe the hook
808
+ * anyway and log "applying the approved model ... through the provider this
809
+ * plugin registers" two lines after warning that the registration failed.
810
+ *
811
+ * @returns {string|null} the refusal, or null when the seam is open
812
+ */
813
+ const openModelSeam = () => {
814
+ if (!applyModel) return MODEL_OFF_REASON;
815
+ if (!conversationAccess) return modelHookBlockedReason(pluginId);
816
+ if (typeof api.registerProvider !== "function") {
817
+ return NO_PROVIDER_SURFACE_REASON;
818
+ }
819
+ try {
820
+ // REGISTERED BEFORE WE KNOW WHETHER A MODEL IS SERVED:
821
+ // `providerOverride` names it on a later turn, and a provider registered
822
+ // at that point would be too late. It answers for nothing until
823
+ // `appliedModel` is set, and never for anyone else's provider id.
824
+ api.registerProvider({
825
+ id: PROVIDER_ID,
826
+ label: "PerceptEye Flywheel",
827
+ // The served endpoint is OpenAI-compatible and may need a key, which
828
+ // the control plane names rather than this plugin inventing one. An
829
+ // empty `auth` is accepted (registry-B8eQDFB4.js:2361-2367) but
830
+ // leaves the host's auth surfaces with nothing to show an operator.
831
+ auth: [{
832
+ id: "api_key",
833
+ label: "API key",
834
+ kind: "api_key",
835
+ hint: `Set models.providers.${PROVIDER_ID}.apiKey to the ` +
836
+ "credential your control plane names for the served endpoint.",
837
+ }],
838
+ normalizeTransport: (ctx) => source?.normalizeTransport(ctx),
839
+ });
840
+ } catch (err) {
841
+ // A refused provider registration must not take the prompt half, the
842
+ // turn capture, or the plugin load down with it -- it costs the MODEL
843
+ // half, and only that.
844
+ return providerRefusedReason(err?.message ?? err);
845
+ }
846
+ return null;
847
+ };
848
+
849
+ // Not attempted at all with no key: nothing is read, so there is nothing for
850
+ // a provider to serve, and `unwired` is already the refusal both halves
851
+ // carry.
852
+ const modelBlocked = unwired ? null : openModelSeam();
853
+
854
+ source = createPolicySource({
855
+ pluginId, logger, applyPrompt, applyModel, servedAgent,
856
+ modelBlocked, unwired,
857
+ });
858
+
859
+ // THE CONSENT BOUNDARY, again. With no key there is no transport, nothing
860
+ // is read, and no hook is subscribed -- the guarantee is the absence of the
861
+ // subscription, not a branch inside a handler.
862
+ //
863
+ // SAID OUT LOUD, because it used to be the one not-doing-it path in this
864
+ // package that returned in silence, directly after a startup line telling
865
+ // the operator that production mode "DOES apply the system prompt your
866
+ // control plane has approved".
867
+ if (unwired) {
868
+ logger?.info?.(
869
+ `[${pluginId}] applying nothing the control plane has approved: ` +
870
+ `${unwired}. Turns are still captured locally.`);
871
+ return source;
872
+ }
873
+
874
+ // THE AGENT SCOPE, decided once. A host running several agents with none of
875
+ // them named is the one case where we cannot tell whose bundle this is, and
876
+ // the handlers will then refuse every run. Saying so at startup is the
877
+ // difference between a fixable setting and a feature that silently does
878
+ // nothing.
879
+ if (!servedAgent.id) {
880
+ logger?.warn?.(
881
+ `[${pluginId}] nothing the control plane approves will be applied: ` +
882
+ `${servedAgent.reason}.`);
883
+ }
884
+
885
+ if (applyPrompt) {
886
+ api.on(PROMPT_HOOK_NAME, (event, ctx) => {
887
+ // The host isolates a throwing hook, but it logs a warning per failure
888
+ // and this one sits in front of every turn. One swallowed error is a
889
+ // turn on the host's own prompt; a throw here would be a warning per
890
+ // turn, forever, with our name on it.
891
+ try {
892
+ return source.handle(event, ctx ?? null);
893
+ } catch {
894
+ return undefined;
895
+ }
896
+ });
897
+ } else {
898
+ logger?.info?.(
899
+ `[${pluginId}] the approved system prompt is NOT being applied: ` +
900
+ `${PROMPT_OFF_REASON}. ${PROMPT_HOOK_NAME} is not subscribed.`);
901
+ }
902
+
903
+ // THE MODEL HOOK IS SUBSCRIBED ONLY WHEN THE WHOLE SEAM IS OPEN, and the
904
+ // sentence the operator reads is the SAME STRING the source reports through
905
+ // `modelRefusal`. `api.on` accepts a subscription the host will silently
906
+ // drop, and `providerOverride` is honoured whether or not a provider answers
907
+ // for the id, so the guarantee has to be the absence of the subscription.
908
+ if (modelBlocked) {
909
+ // The kill switch is the operator's own instruction; the rest are things
910
+ // they would want to know about.
911
+ const write = modelBlocked === MODEL_OFF_REASON ? logger?.info : logger?.warn;
912
+ write?.call(logger,
913
+ `[${pluginId}] the approved model is NOT being applied: ` +
914
+ `${modelBlocked}. ${MODEL_HOOK_NAME} is not subscribed. The prompt ` +
915
+ "half is unaffected.");
916
+ } else {
917
+ api.on(MODEL_HOOK_NAME, (event, ctx) => {
918
+ try {
919
+ return source.handleModelResolve(event, ctx ?? null);
920
+ } catch {
921
+ return undefined;
922
+ }
923
+ });
924
+ }
925
+
926
+ // NOT AWAITED and never rejecting: `register()` must return to the host
927
+ // synchronously, and a slow control plane must not delay a plugin load.
928
+ Promise.resolve(after)
929
+ .catch(() => {})
930
+ .then(() => source.resolve(transport))
931
+ .catch(() => {});
932
+
933
+ return source;
934
+ }