@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/http.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one HTTP client, with retry semantics that READ THE RESPONSE.
|
|
3
|
+
*
|
|
4
|
+
* A port of `agent_flywheel/_http.py`, kept deliberately
|
|
5
|
+
* line-for-line in behaviour because the two are one contract with one server.
|
|
6
|
+
* Its docstring records what it was written against: a previous SDK whose
|
|
7
|
+
* retry helper returned the response without reading its status -- so a 503
|
|
8
|
+
* was indistinguishable from success -- and whose only recovery from an
|
|
9
|
+
* unexpected error was a blanket sleep inside a bare `except` that also
|
|
10
|
+
* swallowed real bugs.
|
|
11
|
+
*
|
|
12
|
+
* Zero dependencies. `fetch` and `AbortController` are builtins in Node 22+,
|
|
13
|
+
* which `engines` already requires, so this adds nothing to a customer's tree.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { CONTRACT_VERSION } from "./wire.js";
|
|
19
|
+
import { LeaseLost, TransportError } from "./errors.js";
|
|
20
|
+
|
|
21
|
+
/** Transient. Retry these, honouring Retry-After when present. */
|
|
22
|
+
const RETRY_STATUS = new Set([408, 429, 500, 502, 503, 504]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Node's fetch rejects with a TypeError whose `cause.code` names the syscall
|
|
26
|
+
* failure. These are the codes where the connection was NEVER ESTABLISHED, so
|
|
27
|
+
* the server cannot have seen the request.
|
|
28
|
+
*
|
|
29
|
+
* A TIMEOUT IS NOT IN THIS SET, and that is the whole point of the
|
|
30
|
+
* distinction: a read timeout may have been fully processed by the server, so
|
|
31
|
+
* resending it performs the operation twice. Python names the same split with
|
|
32
|
+
* `_NEVER_SENT = (httpx.ConnectError, httpx.ConnectTimeout)`.
|
|
33
|
+
*/
|
|
34
|
+
const NEVER_SENT_CODES = new Set([
|
|
35
|
+
"ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ECONNRESET",
|
|
36
|
+
"EHOSTUNREACH", "ENETUNREACH", "EADDRNOTAVAIL",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ONE version, read from the file npm publishes it in.
|
|
41
|
+
*
|
|
42
|
+
* This was a hand-typed literal beside two other copies of the same number
|
|
43
|
+
* (package.json and openclaw.plugin.json). `npm version patch` rewrites
|
|
44
|
+
* package.json and nothing else, so the literal would go on announcing the
|
|
45
|
+
* previous release to the control plane and a bug reported against 0.1.1
|
|
46
|
+
* would be attributed server-side to 0.1.0. npm puts package.json in every
|
|
47
|
+
* tarball whether or not `files` names it, so this read cannot miss.
|
|
48
|
+
*
|
|
49
|
+
* The manifest's copy cannot be derived -- the host reads it as static JSON
|
|
50
|
+
* before any of this runs -- so it is held equal by a test instead
|
|
51
|
+
* (test/version.test.js).
|
|
52
|
+
*/
|
|
53
|
+
export const SDK_VERSION = JSON.parse(
|
|
54
|
+
readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8"),
|
|
55
|
+
).version;
|
|
56
|
+
|
|
57
|
+
export const SDK_UA = `agent-flywheel-node/${SDK_VERSION}`;
|
|
58
|
+
|
|
59
|
+
function neverSent(err) {
|
|
60
|
+
// An AbortError is our own timeout firing. The request was in flight; the
|
|
61
|
+
// server may have completed it. Ambiguous, never "never sent".
|
|
62
|
+
if (err?.name === "AbortError" || err?.name === "TimeoutError") return false;
|
|
63
|
+
const code = err?.cause?.code ?? err?.code;
|
|
64
|
+
return typeof code === "string" && NEVER_SENT_CODES.has(code);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Exponential with FULL JITTER, capped at 8s. The jitter is load-bearing: a
|
|
69
|
+
* fleet of agents that all retry on the same schedule reconverges into exactly
|
|
70
|
+
* the spike that caused the backoff.
|
|
71
|
+
*/
|
|
72
|
+
function backoffMs(attempt) {
|
|
73
|
+
return Math.random() * Math.min(8000, 500 * 2 ** (attempt - 1));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function retryAfterMs(response) {
|
|
77
|
+
const raw = response.headers.get("retry-after");
|
|
78
|
+
if (!raw) return null;
|
|
79
|
+
const seconds = Number(raw);
|
|
80
|
+
return Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function detail(response) {
|
|
84
|
+
let text = "";
|
|
85
|
+
try {
|
|
86
|
+
text = await response.text();
|
|
87
|
+
} catch {
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const body = JSON.parse(text);
|
|
92
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
93
|
+
for (const key of ["detail", "error", "message", "reason"]) {
|
|
94
|
+
if (key in body) return String(body[key]).slice(0, 300);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return String(text).slice(0, 300);
|
|
98
|
+
} catch {
|
|
99
|
+
return text.slice(0, 300);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
104
|
+
|
|
105
|
+
export class ControlPlaneClient {
|
|
106
|
+
/**
|
|
107
|
+
* @param {string} baseUrl
|
|
108
|
+
* @param {string} apiKey
|
|
109
|
+
* @param {{timeoutMs?: number, maxAttempts?: number, fetchImpl?: Function}} [opts]
|
|
110
|
+
*/
|
|
111
|
+
constructor(baseUrl, apiKey, opts = {}) {
|
|
112
|
+
if (!baseUrl) throw new TransportError("control-plane base URL is required");
|
|
113
|
+
if (!apiKey) {
|
|
114
|
+
// NO "dev-key" DEFAULT. A previous SDK defaulted a missing key to a
|
|
115
|
+
// literal placeholder, so a misconfigured deployment ran happily and
|
|
116
|
+
// reported nothing at all.
|
|
117
|
+
throw new TransportError(
|
|
118
|
+
"an API key is required; refusing to start with no credential",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
122
|
+
this._timeoutMs = opts.timeoutMs ?? 30_000;
|
|
123
|
+
this._maxAttempts = Math.max(1, opts.maxAttempts ?? 4);
|
|
124
|
+
this._fetch = opts.fetchImpl ?? globalThis.fetch;
|
|
125
|
+
this._headers = {
|
|
126
|
+
Authorization: `Bearer ${apiKey}`,
|
|
127
|
+
"Contract-Version": CONTRACT_VERSION,
|
|
128
|
+
"X-Percepteye-Sdk": SDK_UA,
|
|
129
|
+
Accept: "application/json",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Send one request, retrying transient failures.
|
|
135
|
+
*
|
|
136
|
+
* `retryIfAmbiguous: false` narrows retries to failures where the request
|
|
137
|
+
* PROVABLY never reached the server. Use it for any call whose server side
|
|
138
|
+
* has a side effect and no replay protection -- retrying a request that MAY
|
|
139
|
+
* have been processed performs it twice.
|
|
140
|
+
*/
|
|
141
|
+
async request(method, path, opts = {}) {
|
|
142
|
+
const {
|
|
143
|
+
jsonBody, idempotencyKey, timeoutMs, retryIfAmbiguous = true,
|
|
144
|
+
headers: requestHeaders = null,
|
|
145
|
+
} = opts;
|
|
146
|
+
const url = `${this.baseUrl}${path}`;
|
|
147
|
+
const headers = { ...this._headers };
|
|
148
|
+
|
|
149
|
+
// Per-request evidence such as the current rollout lease belongs beside
|
|
150
|
+
// the client-owned authentication and contract headers, never in place of
|
|
151
|
+
// them. Header names are case-insensitive, so protect the whole set by its
|
|
152
|
+
// lowercase spelling rather than only the exact casing used above.
|
|
153
|
+
const protectedHeaders = new Set([
|
|
154
|
+
...Object.keys(this._headers).map((name) => name.toLowerCase()),
|
|
155
|
+
"content-type",
|
|
156
|
+
"idempotency-key",
|
|
157
|
+
]);
|
|
158
|
+
if (requestHeaders !== null && requestHeaders !== undefined) {
|
|
159
|
+
if (
|
|
160
|
+
typeof requestHeaders !== "object"
|
|
161
|
+
|| Array.isArray(requestHeaders)
|
|
162
|
+
) {
|
|
163
|
+
throw new TransportError("request headers must be an object");
|
|
164
|
+
}
|
|
165
|
+
for (const [name, value] of Object.entries(requestHeaders)) {
|
|
166
|
+
if (protectedHeaders.has(name.toLowerCase())) {
|
|
167
|
+
throw new TransportError(
|
|
168
|
+
`request header ${JSON.stringify(name)} is owned by the control-plane client`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (typeof value !== "string") {
|
|
172
|
+
throw new TransportError(
|
|
173
|
+
`request header ${JSON.stringify(name)} must be a string`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
headers[name] = value;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
|
180
|
+
if (jsonBody !== undefined) headers["Content-Type"] = "application/json";
|
|
181
|
+
|
|
182
|
+
let lastErr = null;
|
|
183
|
+
|
|
184
|
+
for (let attempt = 1; attempt <= this._maxAttempts; attempt += 1) {
|
|
185
|
+
const controller = new AbortController();
|
|
186
|
+
const timer = setTimeout(
|
|
187
|
+
() => controller.abort(),
|
|
188
|
+
timeoutMs ?? this._timeoutMs,
|
|
189
|
+
);
|
|
190
|
+
let response;
|
|
191
|
+
try {
|
|
192
|
+
response = await this._fetch(url, {
|
|
193
|
+
method,
|
|
194
|
+
headers,
|
|
195
|
+
body: jsonBody === undefined ? undefined : JSON.stringify(jsonBody),
|
|
196
|
+
signal: controller.signal,
|
|
197
|
+
});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
lastErr = `${err?.name ?? "Error"}: ${err?.message ?? err}`;
|
|
200
|
+
if (attempt === this._maxAttempts || !(retryIfAmbiguous || neverSent(err))) break;
|
|
201
|
+
await sleep(backoffMs(attempt));
|
|
202
|
+
continue;
|
|
203
|
+
} finally {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (response.status === 410) {
|
|
208
|
+
throw new LeaseLost((await detail(response)) || "lease lost");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 429 retries EVEN WHEN ambiguous retries are off: it is the server
|
|
212
|
+
// explicitly telling us it did not do the work. Python has the same
|
|
213
|
+
// asymmetry (`status == 429 or (status in RETRY and retry_if_ambiguous)`).
|
|
214
|
+
if (response.status === 429
|
|
215
|
+
|| (RETRY_STATUS.has(response.status) && retryIfAmbiguous)) {
|
|
216
|
+
lastErr = `HTTP ${response.status}: ${await detail(response)}`;
|
|
217
|
+
if (attempt === this._maxAttempts) break;
|
|
218
|
+
await sleep(retryAfterMs(response) ?? backoffMs(attempt));
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (response.status >= 400) {
|
|
223
|
+
// Terminal. A 4xx that is not 408/429 will not become a 2xx by being
|
|
224
|
+
// sent again; retrying only delays the error.
|
|
225
|
+
throw new TransportError(
|
|
226
|
+
`${method} ${path} -> HTTP ${response.status}: ${await detail(response)}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const text = await response.text();
|
|
231
|
+
if (!text) return {};
|
|
232
|
+
let body;
|
|
233
|
+
try {
|
|
234
|
+
body = JSON.parse(text);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
throw new TransportError(
|
|
237
|
+
`${method} ${path} -> HTTP ${response.status} with unparseable body: ${err.message}`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return body && typeof body === "object" && !Array.isArray(body)
|
|
241
|
+
? body
|
|
242
|
+
: { data: body };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
throw new TransportError(
|
|
246
|
+
`${method} ${path} failed after ${this._maxAttempts} attempts: ${lastErr}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-flywheel — an OpenClaw plugin that records tool-call outcomes.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS HOOK. OpenClaw exposes three places to see a tool result, and only
|
|
5
|
+
* one of them is both complete and incapable of changing the run:
|
|
6
|
+
*
|
|
7
|
+
* registerAgentToolResultMiddleware
|
|
8
|
+
* A waterfall. Its return value REPLACES the result the model reads --
|
|
9
|
+
* that is what the bundled output-compacting plugin uses it for. A bug
|
|
10
|
+
* here would silently rewrite a customer's agent output. It is also
|
|
11
|
+
* manifest-gated and refused unless the plugin is explicitly enabled.
|
|
12
|
+
* Rejected: a recorder must not sit on a mutation seam.
|
|
13
|
+
*
|
|
14
|
+
* registerAgentEventSubscription (streams: ["tool"])
|
|
15
|
+
* A genuine observer -- the host `structuredClone`s the event and discards
|
|
16
|
+
* the return. But it is a UI/monitor stream: `data` is untyped, the
|
|
17
|
+
* arguments arrive on a separate `phase:"start"` event, and exec results
|
|
18
|
+
* are capped before dispatch. Correlating the two phases needs a pending
|
|
19
|
+
* map that either leaks or drops.
|
|
20
|
+
*
|
|
21
|
+
* api.on("after_tool_call", ...) <-- what we use
|
|
22
|
+
* Also void-returning, so structurally incapable of altering, blocking or
|
|
23
|
+
* delaying anything; the host runs it best-effort inside its own
|
|
24
|
+
* try/catch. And it carries the whole call in one event: `params`,
|
|
25
|
+
* `result`, `error`, `durationMs`, `toolCallId`, plus agent and session
|
|
26
|
+
* identity on the context.
|
|
27
|
+
*
|
|
28
|
+
* The rule behind the choice: capturing evidence must never be able to change
|
|
29
|
+
* the behaviour being measured. A collector that can corrupt the run it is
|
|
30
|
+
* measuring is worse than no collector.
|
|
31
|
+
*
|
|
32
|
+
* Zero dependencies.
|
|
33
|
+
*
|
|
34
|
+
* WHAT THIS WRITES, PER MODE. This used to read "inert unless
|
|
35
|
+
* PERCEPTEYE_TRAJECTORY_DIR is set, so one install is a no-op in production and
|
|
36
|
+
* a recorder during a rollout". Once production mode gained turn capture that
|
|
37
|
+
* was not merely stale, it was INVERTED -- production became the mode that
|
|
38
|
+
* writes unconditionally -- and what it understated was the recording of
|
|
39
|
+
* somebody's END USERS' message text. The truth:
|
|
40
|
+
*
|
|
41
|
+
* training tool outcomes go to the rollout's PERCEPTEYE_TRAJECTORY_DIR.
|
|
42
|
+
* With the variable unset nothing is written.
|
|
43
|
+
* production every served turn -- the user's message, the tool calls, and
|
|
44
|
+
* (with allowConversationAccess) the agent's answer -- is written
|
|
45
|
+
* under PERCEPTEYE_CAPTURE_DIR, default ~/.percepteye/captured,
|
|
46
|
+
* whether or not PERCEPTEYE_TRAJECTORY_DIR is set. Nothing is
|
|
47
|
+
* uploaded without a key AND the control plane's per-agent
|
|
48
|
+
* consent, but it is on disk either way.
|
|
49
|
+
*
|
|
50
|
+
* The off switch is `capture: false` / PERCEPTEYE_CAPTURE=0, which returns from
|
|
51
|
+
* `register` before any hook is subscribed, in either mode.
|
|
52
|
+
*
|
|
53
|
+
* WHAT PRODUCTION MODE ALSO DOES, and it is not observation. It applies BOTH
|
|
54
|
+
* halves of what the control plane has APPROVED for this agent:
|
|
55
|
+
*
|
|
56
|
+
* the system prompt `{systemPrompt}` returned from `before_prompt_build`,
|
|
57
|
+
* a full replacement of the prompt the host assembled.
|
|
58
|
+
* the model `{providerOverride, modelOverride}` returned from
|
|
59
|
+
* `before_model_resolve`, plus a registered provider
|
|
60
|
+
* whose `normalizeTransport` supplies the base URL. That
|
|
61
|
+
* hook is CONVERSATION-gated, so it needs the same
|
|
62
|
+
* `allowConversationAccess=true` opt-in that capturing
|
|
63
|
+
* the agent's answer needs.
|
|
64
|
+
*
|
|
65
|
+
* They are a certified PAIR, which is why they are applied together and why
|
|
66
|
+
* applying one alone is reported as such. That is the last mile of the loop
|
|
67
|
+
* this package exists to close, and the only thing here that changes what the
|
|
68
|
+
* agent does rather than recording it. `policy.js` holds both decisions, the
|
|
69
|
+
* pair check, the per-agent scope, and the two off switches --
|
|
70
|
+
* `applyPrompt: false` / PERCEPTEYE_APPLY_PROMPT=0 and `applyModel: false` /
|
|
71
|
+
* PERCEPTEYE_APPLY_MODEL=0.
|
|
72
|
+
*/
|
|
73
|
+
import { createRecorder } from "./record.js";
|
|
74
|
+
import { createWriter, TRAJECTORY_DIR_ENV } from "./trajectory.js";
|
|
75
|
+
import { registerDescriber, DESCRIBE_HOOK_NAME } from "./describe.js";
|
|
76
|
+
import { CONFIG_SCHEMA, resolveConfig } from "./config.js";
|
|
77
|
+
import { ControlPlaneClient } from "./http.js";
|
|
78
|
+
import { AttachTransport } from "./transport.js";
|
|
79
|
+
import { PRODUCTION, TRAINING } from "./mode.js";
|
|
80
|
+
import { ConfigurationError } from "./errors.js";
|
|
81
|
+
import { registerRolloutDriver } from "./rollout.js";
|
|
82
|
+
import { PENDING_ANSWER_REASON, registerServingPolicy } from "./policy.js";
|
|
83
|
+
import { conversationAccessGranted } from "./host.js";
|
|
84
|
+
import {
|
|
85
|
+
captureRoot, createTurnCapture, registerTurnCapture,
|
|
86
|
+
} from "./capture.js";
|
|
87
|
+
import { createExecutionIdentityTracker } from "./execution-identity.js";
|
|
88
|
+
|
|
89
|
+
export const PLUGIN_ID = "agent-flywheel";
|
|
90
|
+
export const HOOK_NAME = "after_tool_call";
|
|
91
|
+
export { DESCRIBE_HOOK_NAME };
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
export function register(api, pluginConfig = {}, env = process.env) {
|
|
95
|
+
if (typeof api?.on !== "function") return null;
|
|
96
|
+
|
|
97
|
+
const logger = api.logger ?? api.runtime?.logger ?? null;
|
|
98
|
+
|
|
99
|
+
// THE KILL SWITCH, honoured. `openclaw.plugin.json` promises "Set
|
|
100
|
+
// false/0/off to disable local capture entirely", and `resolveConfig`
|
|
101
|
+
// computed `captureEnabled` for exactly that -- but nothing read it, and
|
|
102
|
+
// `trajectory.js` records on the trajectory dir alone. A customer who set
|
|
103
|
+
// PERCEPTEYE_CAPTURE=0 kept being recorded, which is the worst kind of
|
|
104
|
+
// defect: a promise about somebody's data that the code does not keep.
|
|
105
|
+
//
|
|
106
|
+
// `pluginConfig` is a SECOND parameter with a default rather than a
|
|
107
|
+
// required one, so this works whether or not the host passes config to
|
|
108
|
+
// register(). Where it does, config wins; where it does not, the
|
|
109
|
+
// environment still switches capture off. A fix that only worked once we
|
|
110
|
+
// learned the host's calling convention would leave the promise broken in
|
|
111
|
+
// the meantime.
|
|
112
|
+
// `env` is a parameter, not a read of the global, so the gate is testable
|
|
113
|
+
// without mutating process.env under a parallel test runner -- and it
|
|
114
|
+
// matches resolveConfig's own signature rather than inventing a second
|
|
115
|
+
// convention for the same thing.
|
|
116
|
+
// WHERE THE HOST ACTUALLY PUTS CONFIG: `api.pluginConfig`
|
|
117
|
+
// (OpenClaw 2026.7.1-2, dist/types-DaHgOqFX.d.ts:12076 declares
|
|
118
|
+
// `pluginConfig?: Record<string, unknown>` on the plugin api). The loader
|
|
119
|
+
// validates `plugins.entries.<id>.config` against the MANIFEST's
|
|
120
|
+
// configSchema and hands the result over on the api object -- it does NOT
|
|
121
|
+
// pass it as a second argument to register().
|
|
122
|
+
//
|
|
123
|
+
// Reading only an argument therefore honoured PERCEPTEYE_CAPTURE from the
|
|
124
|
+
// environment and IGNORED `capture: false` in the customer's OpenClaw
|
|
125
|
+
// config: half the promise, and the half the manifest documents first. The
|
|
126
|
+
// explicit parameter is kept because it is what makes this gate testable
|
|
127
|
+
// without constructing a whole host api.
|
|
128
|
+
const supplied = Object.keys(pluginConfig).length
|
|
129
|
+
? pluginConfig
|
|
130
|
+
: (api.pluginConfig ?? {});
|
|
131
|
+
let config;
|
|
132
|
+
try {
|
|
133
|
+
config = resolveConfig(supplied, env);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (!(err instanceof ConfigurationError)) throw err;
|
|
136
|
+
// AN UNREADABLE MODE DEGRADES TO PRODUCTION, LOUDLY. It does not throw
|
|
137
|
+
// and it does not fall back to `training`, and both of those matter.
|
|
138
|
+
//
|
|
139
|
+
// Throwing is what the Python SDK does, and it is right THERE: `serve()`
|
|
140
|
+
// is a process entry point, so refusing to start is the whole remedy. Here
|
|
141
|
+
// the same throw would fail the plugin load and take LOCAL CAPTURE down
|
|
142
|
+
// with it -- the one feature that works with no account, no key and no
|
|
143
|
+
// control plane -- over a typo in a setting it does not depend on.
|
|
144
|
+
//
|
|
145
|
+
// Falling back to `training` would be worse than either: someone typing
|
|
146
|
+
// `PERCEPTEYE_AGENT_MODE=prodction` is trying to STOP contributing, and a
|
|
147
|
+
// default that resumes claiming rollouts on their credentials is the exact
|
|
148
|
+
// failure the closed alias set exists to prevent. Production is the safe
|
|
149
|
+
// side of a mode we could not read: the worst case is that this install
|
|
150
|
+
// does less than intended, and says so.
|
|
151
|
+
config = resolveConfig({ ...supplied, mode: PRODUCTION }, env);
|
|
152
|
+
logger?.warn?.(
|
|
153
|
+
`[${PLUGIN_ID}] ${err.message} Running in ${PRODUCTION} mode until ` +
|
|
154
|
+
`this is corrected; no rollouts will be claimed. Note that production ` +
|
|
155
|
+
`mode is not inert: it captures served turns and applies the approved ` +
|
|
156
|
+
`system prompt (PERCEPTEYE_CAPTURE=0 / PERCEPTEYE_APPLY_PROMPT=0).`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (!config.captureEnabled) {
|
|
160
|
+
logger?.info?.(
|
|
161
|
+
`[${PLUGIN_ID}] capture is disabled by configuration; recording nothing. ` +
|
|
162
|
+
`Unset PERCEPTEYE_CAPTURE (or plugins.entries.${PLUGIN_ID}.config.capture) ` +
|
|
163
|
+
`to re-enable.`,
|
|
164
|
+
);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
const executionIdentity = createExecutionIdentityTracker({
|
|
168
|
+
executionSnapshot: config.executionSnapshot,
|
|
169
|
+
mode: config.mode,
|
|
170
|
+
agentId: config.agentId,
|
|
171
|
+
});
|
|
172
|
+
if (config.executionSnapshot !== null && !executionIdentity.enabled) {
|
|
173
|
+
logger?.warn?.(
|
|
174
|
+
`[${PLUGIN_ID}] exact execution identity is disabled: ` +
|
|
175
|
+
`${executionIdentity.refusal}. Capture continues without an affirmative ` +
|
|
176
|
+
`execution fingerprint.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
// ── WHERE TOOL CALLS LAND, decided by mode ────────────────────────────
|
|
180
|
+
//
|
|
181
|
+
// In TRAINING mode a trajectory belongs to one ROLLOUT, and the rollout
|
|
182
|
+
// driver names the directory through PERCEPTEYE_TRAJECTORY_DIR. Unchanged.
|
|
183
|
+
//
|
|
184
|
+
// In PRODUCTION mode there is no rollout: a trajectory belongs to one TURN,
|
|
185
|
+
// so each call is routed to its own turn directory by `runId`. The env var
|
|
186
|
+
// is not consulted there, because a single shared directory would merge
|
|
187
|
+
// every end user's calls into one trajectory -- and `PERCEPTEYE_AGENT_MODE`
|
|
188
|
+
// is the authority on which of the two this process is.
|
|
189
|
+
const turns = config.mode === PRODUCTION
|
|
190
|
+
? createTurnCapture({ root: captureRoot(env), logger })
|
|
191
|
+
: null;
|
|
192
|
+
const writer = createWriter({
|
|
193
|
+
resolveDir: turns ? turns.resolveToolDir : null,
|
|
194
|
+
onError: (err) => logger?.warn?.(
|
|
195
|
+
`[${PLUGIN_ID}] trajectory write failed; tool outcomes are not being ` +
|
|
196
|
+
`recorded for this run: ${err?.message ?? err}`,
|
|
197
|
+
),
|
|
198
|
+
});
|
|
199
|
+
const recorder = createRecorder({ writer });
|
|
200
|
+
|
|
201
|
+
api.on(HOOK_NAME, (event, ctx) => {
|
|
202
|
+
// The host already isolates hook failures, but it logs a warning per
|
|
203
|
+
// failure. Swallowing here keeps a malformed event from filling a
|
|
204
|
+
// customer's logs with our name -- one lost record, never a failed run,
|
|
205
|
+
// never a noisy one.
|
|
206
|
+
try {
|
|
207
|
+
recorder.handle(event, ctx ?? {});
|
|
208
|
+
} catch {
|
|
209
|
+
/* one lost record is not worth an interrupted agent */
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ── THE MODE SPLIT, STRUCTURALLY ──────────────────────────────────────
|
|
214
|
+
//
|
|
215
|
+
// In production mode the rollout driver is NOT CONSTRUCTED and its three
|
|
216
|
+
// hooks are NOT SUBSCRIBED. That is what makes "claims no work, starts no
|
|
217
|
+
// turn" a property of this file rather than a promise each handler keeps:
|
|
218
|
+
// there is no code path from a production install to a claim, an injection
|
|
219
|
+
// or a report, because the module that could do those things is never
|
|
220
|
+
// reached. A `if (mode === "training")` inside each handler would be the
|
|
221
|
+
// same behaviour with a worse failure mode -- one missed call site is a
|
|
222
|
+
// silent breach instead of a hook that simply is not there.
|
|
223
|
+
//
|
|
224
|
+
// Local capture above is UNAFFECTED by mode. It never leaves the machine,
|
|
225
|
+
// and it is the plugin's day-one value with no account at all.
|
|
226
|
+
let rollouts = null;
|
|
227
|
+
let capture = null;
|
|
228
|
+
let serving = null;
|
|
229
|
+
if (config.mode === TRAINING) {
|
|
230
|
+
// WRAPPED, because the host requires `register` to be SYNCHRONOUS and
|
|
231
|
+
// treats a throw here as a failed plugin load (loader-D8d2EvVh.js:762).
|
|
232
|
+
// `new ControlPlaneClient` refuses a missing key or base URL by design, so
|
|
233
|
+
// a misconfigured control plane would otherwise take the RECORDER down
|
|
234
|
+
// with it -- losing the local capture that works without any of this.
|
|
235
|
+
try {
|
|
236
|
+
rollouts = registerRolloutDriver(api, {
|
|
237
|
+
config,
|
|
238
|
+
pluginId: PLUGIN_ID,
|
|
239
|
+
logger,
|
|
240
|
+
transport: config.apiKey
|
|
241
|
+
? new AttachTransport(
|
|
242
|
+
new ControlPlaneClient(config.controlPlaneUrl, config.apiKey),
|
|
243
|
+
{ agentId: config.agentId },
|
|
244
|
+
)
|
|
245
|
+
: null,
|
|
246
|
+
executionIdentity,
|
|
247
|
+
});
|
|
248
|
+
} catch (err) {
|
|
249
|
+
logger?.warn?.(
|
|
250
|
+
`[${PLUGIN_ID}] rollouts are disabled for this run: ` +
|
|
251
|
+
`${err?.message ?? err}. Local capture is unaffected.`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
// WHAT PRODUCTION MODE PROMISES, said exactly, and CONDITIONAL ON WHAT
|
|
256
|
+
// WILL ACTUALLY HAPPEN. This used to end "and no prompt is injected --
|
|
257
|
+
// those hooks are not registered at all", which stopped being true the
|
|
258
|
+
// moment `registerServingPolicy` below started subscribing
|
|
259
|
+
// `before_prompt_build`. Then it promised the opposite unconditionally --
|
|
260
|
+
// "It DOES ... apply the system prompt your control plane has approved" --
|
|
261
|
+
// and on a keyless install, which is the ordinary first install, that was
|
|
262
|
+
// false in the other direction: with no key there is no transport,
|
|
263
|
+
// `registerServingPolicy` subscribes nothing, and the operator was told
|
|
264
|
+
// otherwise by the line immediately before the silence.
|
|
265
|
+
//
|
|
266
|
+
// So the claim is READ OFF THE SOURCE, after it has wired itself, rather
|
|
267
|
+
// than re-derived here. It used to be computed from three facts -- a key,
|
|
268
|
+
// the kill switch, the conversation opt-in -- and there are more than
|
|
269
|
+
// three: a host that has no provider surface, or that refuses the provider
|
|
270
|
+
// id, rules the model half out too, and only `registerServingPolicy` knows
|
|
271
|
+
// that. A predicate assembled here from a SUBSET of the source's own
|
|
272
|
+
// inputs is a second answer that drifts the moment a cause is added.
|
|
273
|
+
const conversationAccess = conversationAccessGranted(api, PLUGIN_ID);
|
|
274
|
+
capture = registerTurnCapture(api, {
|
|
275
|
+
config, turns, pluginId: PLUGIN_ID, logger, env,
|
|
276
|
+
});
|
|
277
|
+
// THE LAST MILE. `capture` owns the one control-plane client, so this
|
|
278
|
+
// reuses it rather than building a second against the same key -- and
|
|
279
|
+
// waits on the registration chain rather than racing it.
|
|
280
|
+
serving = registerServingPolicy(api, {
|
|
281
|
+
config,
|
|
282
|
+
pluginId: PLUGIN_ID,
|
|
283
|
+
logger,
|
|
284
|
+
transport: capture?.transport ?? null,
|
|
285
|
+
after: capture?.started ?? null,
|
|
286
|
+
// The host's own config, for the agent scope, and the host's own answer
|
|
287
|
+
// about whether it will deliver `before_model_resolve` at all.
|
|
288
|
+
hostConfig: api?.config,
|
|
289
|
+
conversationAccess,
|
|
290
|
+
});
|
|
291
|
+
// PENDING is the only value either half can hold here that is not a
|
|
292
|
+
// settled refusal: the read has not happened yet, so anything else is a
|
|
293
|
+
// cause that has already ruled that half out.
|
|
294
|
+
const applying = [
|
|
295
|
+
serving.refusal === PENDING_ANSWER_REASON ? "the system prompt" : null,
|
|
296
|
+
serving.modelRefusal === PENDING_ANSWER_REASON ? "the model" : null,
|
|
297
|
+
].filter(Boolean);
|
|
298
|
+
logger?.info?.(
|
|
299
|
+
`[${PLUGIN_ID}] mode=${config.mode} (from ${config.modeSource}): this ` +
|
|
300
|
+
`install claims no rollout and starts no turn -- those hooks are not ` +
|
|
301
|
+
`registered at all. It DOES capture the turns you serve. ` +
|
|
302
|
+
(applying.length
|
|
303
|
+
? `It also applies ${applying.join(" and ")} your control plane has ` +
|
|
304
|
+
"approved."
|
|
305
|
+
: "It applies NOTHING your control plane has approved; the lines " +
|
|
306
|
+
"above say why.") +
|
|
307
|
+
` Tool outcomes are still recorded locally.`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// The describer answers a different question from the recorder: not "what
|
|
312
|
+
// did this call do" but "what IS this agent". See describe.js -- it is the
|
|
313
|
+
// only way a CLI agent can reach `introspection_state = described`, because
|
|
314
|
+
// Python reflection cannot cross a process and a language boundary.
|
|
315
|
+
const describer = registerDescriber(api, {
|
|
316
|
+
logger,
|
|
317
|
+
// Identity rides the same observer hook but is independent of description
|
|
318
|
+
// persistence: it must work in production, where the trajectory env var
|
|
319
|
+
// is normally absent and the describer writes nothing.
|
|
320
|
+
onObservation: (event, ctx) => {
|
|
321
|
+
const observation = executionIdentity.observe(event, ctx ?? {});
|
|
322
|
+
if (turns && observation) {
|
|
323
|
+
turns.onExecutionIdentity(event, ctx ?? {}, observation);
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// `llm_input` is a CONVERSATION hook, and the host REFUSES to register it
|
|
329
|
+
// for a non-bundled plugin unless the operator sets
|
|
330
|
+
// `plugins.entries.<id>.hooks.allowConversationAccess=true`. The refusal is
|
|
331
|
+
// a warn-level diagnostic on the host's side, so without this line a
|
|
332
|
+
// missing opt-in is indistinguishable from a working install: the plugin
|
|
333
|
+
// loads, the recorder works, and the agent registers as `unreadable`
|
|
334
|
+
// forever with nothing anywhere saying why. Said once at startup, and only
|
|
335
|
+
// when a trajectory dir makes it relevant.
|
|
336
|
+
if (describer?.active) {
|
|
337
|
+
logger?.info?.(
|
|
338
|
+
`[${PLUGIN_ID}] describing this agent via ${DESCRIBE_HOOK_NAME}. That is ` +
|
|
339
|
+
`a conversation hook: if you do not see ${"discovered_agent.json"} in ` +
|
|
340
|
+
`${TRAJECTORY_DIR_ENV} after the first model call, set ` +
|
|
341
|
+
`plugins.entries.${PLUGIN_ID}.hooks.allowConversationAccess=true in ` +
|
|
342
|
+
`your OpenClaw config -- the host blocks the hook without it.`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
recorder, writer, describer, rollouts, capture, serving, executionIdentity,
|
|
348
|
+
mode: config.mode,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export default {
|
|
353
|
+
id: PLUGIN_ID,
|
|
354
|
+
name: "PerceptEye Agent Flywheel",
|
|
355
|
+
// THE STRING `openclaw plugins list` PRINTS, so it has to be true of the
|
|
356
|
+
// install the reader is looking at. It said "Inert unless
|
|
357
|
+
// PERCEPTEYE_TRAJECTORY_DIR is set" while production mode captured every
|
|
358
|
+
// served turn to disk with that variable unset -- an operator who left the
|
|
359
|
+
// plugin installed "because it is inert" was accumulating their end users'
|
|
360
|
+
// messages.
|
|
361
|
+
description:
|
|
362
|
+
"Records tool-call outcomes (ok/failed/unknown). In training mode they " +
|
|
363
|
+
`go to the active rollout's trajectory under ${TRAJECTORY_DIR_ENV}. In ` +
|
|
364
|
+
"production mode every served turn -- the user's message, the tool " +
|
|
365
|
+
"calls and the agent's answer -- is captured under " +
|
|
366
|
+
"PERCEPTEYE_CAPTURE_DIR (default ~/.percepteye/captured) even when " +
|
|
367
|
+
`${TRAJECTORY_DIR_ENV} is unset, and uploaded only with a key and the ` +
|
|
368
|
+
"control plane's per-agent consent; production mode also APPLIES what " +
|
|
369
|
+
"your control plane has approved for this agent -- the system prompt, and " +
|
|
370
|
+
"the model it was certified with -- which changes what the agent does. " +
|
|
371
|
+
"Set capture=false (or PERCEPTEYE_CAPTURE=0) to record nothing, " +
|
|
372
|
+
"applyPrompt=false (or PERCEPTEYE_APPLY_PROMPT=0) to leave the prompt " +
|
|
373
|
+
"alone, applyModel=false (or PERCEPTEYE_APPLY_MODEL=0) to leave the model " +
|
|
374
|
+
"alone.",
|
|
375
|
+
// THE SAME OBJECT the manifest declares, not a second one. This used to be
|
|
376
|
+
// `{additionalProperties: false, properties: {}}` -- a schema that rejects
|
|
377
|
+
// every setting openclaw.plugin.json tells a customer to write.
|
|
378
|
+
configSchema: CONFIG_SCHEMA,
|
|
379
|
+
register,
|
|
380
|
+
};
|