@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/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
|
+
}
|