@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.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Turning an OpenClaw `after_tool_call` event into a flywheel outcome.
3
+ *
4
+ * The tri-state is the whole point, and it is NOT "error field absent means
5
+ * success". The host builds the event with
6
+ *
7
+ * ...params.error ? { error: params.error } : {}
8
+ *
9
+ * so `error` is omitted whenever it is falsy -- which covers both "the call
10
+ * succeeded" and "nobody set an error". Those are different facts and this
11
+ * module refuses to merge them.
12
+ *
13
+ * OpenClaw's own durable-log path shows what merging them costs: `itemStatus()`
14
+ * maps any status it does not recognise to "completed", so "cancelled" and
15
+ * "unknown" -- which its own terminal-status audit keeps distinct -- arrive
16
+ * downstream looking like success. We inherit the lesson rather than the bug:
17
+ * a verdict nobody stated is `unknown`, never `ok`.
18
+ */
19
+
20
+ /**
21
+ * Tools whose silence really does mean the operation succeeded. These have no
22
+ * in-band status channel, so a failure has nowhere to go but the error field.
23
+ *
24
+ * The names are the host's own: `bash, edit, exec, find, grep, ls, read, write`
25
+ * are what OpenClaw types as tool names. `apply_patch` is deliberately NOT
26
+ * here -- a patch can apply partially, and until a fixture shows how that is
27
+ * reported, grading it `ok` on silence would be a guess in the dangerous
28
+ * direction. Everything else is `unknown` until proven; promotion is earned,
29
+ * never assumed.
30
+ */
31
+ export const VERDICT_TOOLS = new Set([
32
+ "read", "write", "edit", "grep", "ls", "find",
33
+ ]);
34
+
35
+ /**
36
+ * Tools that report the operation's real verdict INSIDE the result and set no
37
+ * error field for an operation that plainly failed -- a non-zero exit. These
38
+ * two names are the host's own definition of an exec tool
39
+ * (`isExecToolName(n) => n === "exec" || n === "bash"`), so this set tracks
40
+ * the host rather than guessing at aliases.
41
+ */
42
+ export const IN_BAND_TOOLS = new Set(["bash", "exec"]);
43
+
44
+ const EXIT_PATHS = [
45
+ ["meta", "exitCode"], ["meta", "exit_code"], ["meta", "status"],
46
+ ["details", "exitCode"], ["details", "status"],
47
+ ["exitCode"], ["exit_code"],
48
+ ];
49
+
50
+ function dig(obj, path) {
51
+ let cur = obj;
52
+ for (const key of path) {
53
+ if (cur === null || typeof cur !== "object") return undefined;
54
+ cur = cur[key];
55
+ }
56
+ return cur;
57
+ }
58
+
59
+ /** The exit status if one is legible, else undefined. Never a guess. */
60
+ export function readExit(result) {
61
+ for (const path of EXIT_PATHS) {
62
+ const v = dig(result, path);
63
+ if (typeof v === "number" && Number.isFinite(v)) return v;
64
+ if (typeof v === "string" && /^-?\d+$/.test(v)) return Number(v);
65
+ if (v === "error" || v === "failed") return 1;
66
+ if (v === "ok" || v === "success") return 0;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ /**
72
+ * @param {string} name tool name, as the host reports it
73
+ * @param {unknown} error the host's error string; present only when truthy
74
+ * @param {unknown} result the tool result
75
+ * @returns {{outcome:"ok"|"failed"|"unknown", status_code:null,
76
+ * error?:string, error_class?:string}}
77
+ */
78
+ export function classify(name, error, result) {
79
+ // A positive assertion by the host that the call failed. Believe it.
80
+ if (typeof error === "string" && error.trim() !== "") {
81
+ return {
82
+ outcome: "failed",
83
+ status_code: null,
84
+ error: error.slice(0, 1000),
85
+ error_class: "ToolError",
86
+ };
87
+ }
88
+
89
+ if (IN_BAND_TOOLS.has(name)) {
90
+ const exit = readExit(result);
91
+ // Not found means we did not observe the verdict. Falling back to `ok`
92
+ // here is the exact rounding-up this module exists to prevent.
93
+ if (exit === undefined) {
94
+ return {
95
+ outcome: "unknown", status_code: null,
96
+ error_class: "ExitStatusUnreadable",
97
+ };
98
+ }
99
+ if (exit === 0) return { outcome: "ok", status_code: null };
100
+ // An exit code is NOT an HTTP status. Putting 127 in `status_code` would
101
+ // fail the contract's 100..599 range check on the Python side, and would
102
+ // be a lie besides.
103
+ return {
104
+ outcome: "failed", status_code: null,
105
+ error_class: `ExitStatus:${exit}`,
106
+ };
107
+ }
108
+
109
+ if (VERDICT_TOOLS.has(name)) return { outcome: "ok", status_code: null };
110
+
111
+ // MCP tools, third-party plugin tools, anything unproven. An MCP server that
112
+ // returns {"error": ...} as ordinary content is invisible from here, so the
113
+ // honest answer is that we did not observe the verdict.
114
+ return { outcome: "unknown", status_code: null };
115
+ }
package/src/config.js ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Where the plugin's settings come from, and the ONE decision that gates the
3
+ * network entirely.
4
+ *
5
+ * THE CONSENT BOUNDARY. If no API key resolves, `contributing()` returns null
6
+ * and every control-plane path stays inert -- no claim, no report, no
7
+ * registration, nothing leaves the machine. Local capture still runs, which is
8
+ * the plugin's day-one value: your agent's real tool-failure rate, no account,
9
+ * no network. A customer with no key is not misconfigured; they are the
10
+ * ordinary first install.
11
+ *
12
+ * Precedence is OpenClaw config first, environment second. The config path is
13
+ * preferred because it is hot-reloaded and puts the secret under OpenClaw's own
14
+ * secret-ref machinery, while a gateway process reads its environment once at
15
+ * launch and cannot be re-keyed without a restart.
16
+ */
17
+ import { resolveModeSource } from "./mode.js";
18
+ import { ConfigurationError } from "./errors.js";
19
+ import {
20
+ PRODUCTION_IDENTIFIER_MAX_CHARS, productionAgentIdentifier,
21
+ } from "./wire.js";
22
+
23
+ export const PLUGIN_ID = "agent-flywheel";
24
+
25
+ /**
26
+ * The settings this plugin accepts, declared ONCE.
27
+ *
28
+ * `openclaw.plugin.json` carries a copy because that is the file the host
29
+ * reads before any of this code loads. The copy is not a second answer: a test
30
+ * asserts the two are identical, which is the only thing that keeps them so --
31
+ * and they had already diverged. `index.js` declared
32
+ * `{additionalProperties: false, properties: {}}`, which does not merely fail
33
+ * to read the settings, it REJECTS them: a customer following the manifest and
34
+ * setting `apiKey` would have had it refused by the schema the module exports.
35
+ *
36
+ * NO PROPERTY HERE DECLARES A `default`, and that absence is load-bearing.
37
+ * OpenClaw validates `plugins.entries.<id>.config` with
38
+ * `validateJsonSchemaValue({..., applyDefaults: true})`
39
+ * (dist/loader-D8d2EvVh.js:1381-1412 -> dist/schema-validator-BRkrm3P2.js:239)
40
+ * and hands the RESULT over as `api.pluginConfig`. A schema default is
41
+ * therefore NOT "what happens when nobody said": the host materialises it into
42
+ * an EXPLICIT config value for every install, including one with no `config`
43
+ * block at all. `mode: "training"` then outranked PERCEPTEYE_AGENT_MODE,
44
+ * `capture: true` outranked PERCEPTEYE_CAPTURE and `agentId: "openclaw"`
45
+ * outranked PERCEPTEYE_AGENT_ID -- so a gateway serving real end users, with
46
+ * PERCEPTEYE_AGENT_MODE=production set exactly as the README instructs, claimed
47
+ * rollouts and started unattended turns anyway, and the operator got no signal
48
+ * because the production-mode log is on the branch that was never taken.
49
+ *
50
+ * `resolveConfig` below is the ONE place a default is decided. A schema default
51
+ * is a second answer to a question already answered there, and this host turns
52
+ * it into the winning one. `test/config-gate.test.js` asserts the absence.
53
+ */
54
+ export const CONFIG_SCHEMA = {
55
+ type: "object",
56
+ additionalProperties: false,
57
+ properties: {
58
+ apiKey: {
59
+ type: "string",
60
+ minLength: 1,
61
+ description:
62
+ "PerceptEye flywheel key (pefw_...). Without it the plugin records " +
63
+ "locally and contacts nothing.",
64
+ },
65
+ agentId: {
66
+ type: "string",
67
+ minLength: 1,
68
+ maxLength: 255,
69
+ pattern: "^(?!\\.{1,2}$)(?!\\s)(?!.*\\s$)(?!.*[\\\\/])[^\\u0000-\\u001F\\u007F-\\u009F]+$",
70
+ description:
71
+ "The agent name this install reports as. Unset means " +
72
+ 'PERCEPTEYE_AGENT_ID, then "openclaw".',
73
+ },
74
+ controlPlaneUrl: {
75
+ type: "string",
76
+ format: "uri",
77
+ description: "Override the control-plane base URL.",
78
+ },
79
+ capture: {
80
+ type: ["string", "boolean"],
81
+ description:
82
+ "Set false/0/off to disable local capture entirely. Unset means " +
83
+ "PERCEPTEYE_CAPTURE, then on.",
84
+ },
85
+ applyPrompt: {
86
+ type: ["string", "boolean"],
87
+ description:
88
+ "Set false/0/off to stop applying the control plane's approved " +
89
+ "system prompt in production mode. Unset means " +
90
+ "PERCEPTEYE_APPLY_PROMPT, then on.",
91
+ },
92
+ applyModel: {
93
+ type: ["string", "boolean"],
94
+ description:
95
+ "Set false/0/off to stop pointing this agent at the control " +
96
+ "plane's approved model in production mode. Unset means " +
97
+ "PERCEPTEYE_APPLY_MODEL, then on. Needs " +
98
+ "hooks.allowConversationAccess=true to take effect at all.",
99
+ },
100
+ executionSnapshot: {
101
+ type: "object",
102
+ additionalProperties: false,
103
+ required: [
104
+ "adapterName", "executionIdentityComplete", "executionComponents",
105
+ ],
106
+ properties: {
107
+ adapterName: { type: "string", minLength: 1 },
108
+ executionIdentityComplete: { type: "boolean" },
109
+ executionComponents: {
110
+ type: "object",
111
+ minProperties: 1,
112
+ propertyNames: { type: "string", minLength: 1 },
113
+ additionalProperties: { type: "string", pattern: "^[0-9a-f]{64}$" },
114
+ },
115
+ startupObservation: {
116
+ type: "object",
117
+ additionalProperties: false,
118
+ required: ["systemPrompt", "tools", "provider", "model"],
119
+ properties: {
120
+ systemPrompt: { type: "string", minLength: 1 },
121
+ tools: {
122
+ type: "array",
123
+ items: { type: "object" },
124
+ },
125
+ provider: { type: "string", minLength: 1 },
126
+ model: { type: "string", minLength: 1 },
127
+ },
128
+ description:
129
+ "Optional exact prompt/tool/provider/model observation available " +
130
+ "before the first claim. It lets registration carry the same " +
131
+ "SDK-authored execution fingerprint later reports must match.",
132
+ },
133
+ },
134
+ description:
135
+ "Host-adapter evidence for exact execution identity. Component names " +
136
+ "are opaque to the SDK; incomplete or invalid evidence emits no identity. " +
137
+ "A startup observation enables pre-claim registration binding.",
138
+ },
139
+ mode: {
140
+ type: "string",
141
+ enum: [
142
+ "training", "train", "rollout", "rollouts", "fine-tuning",
143
+ "finetuning", "improving", "production", "prod", "serving",
144
+ ],
145
+ description:
146
+ "training: this install claims rollouts and drives turns. " +
147
+ "production: claims no rollout and starts no turn -- those hooks are " +
148
+ "never registered -- but captures the turns you serve and applies " +
149
+ "the system prompt your control plane approved. Unset means " +
150
+ "PERCEPTEYE_AGENT_MODE, then training; a value here overrides the " +
151
+ "environment.",
152
+ },
153
+ },
154
+ };
155
+
156
+ /**
157
+ * Where this plugin dials when nothing names a control plane.
158
+ *
159
+ * `launch.percepteye.ai` is the live API domain. Named in the README's
160
+ * configuration table too, because a default a customer cannot see is a
161
+ * default they cannot tell apart from a control plane that is down.
162
+ */
163
+ const DEFAULT_CONTROL_PLANE = "https://launch.percepteye.ai/api/flywheel/v1";
164
+
165
+ /** The spellings that mean OFF. One list, because one typo rule applies. */
166
+ const OFF = ["0", "false", "no", "off"];
167
+
168
+ /** `pluginConfig.<key> ?? env.<ENV>`, ON unless DELIBERATELY switched off. */
169
+ const enabledUnlessOff = (configured, fromEnv) =>
170
+ !OFF.includes(String(configured ?? fromEnv ?? "").trim().toLowerCase());
171
+
172
+ const firstString = (...values) => {
173
+ for (const v of values) {
174
+ if (typeof v === "string" && v.trim()) return v.trim();
175
+ }
176
+ return null;
177
+ };
178
+
179
+ const agentIdentifier = (...values) => {
180
+ for (const value of values) {
181
+ if (value === undefined || value === null || value === "") continue;
182
+ const exact = productionAgentIdentifier(value);
183
+ if (exact !== null) return exact;
184
+ throw new ConfigurationError(
185
+ "agentId must be a non-empty, control-free identifier without edge " +
186
+ "whitespace, path separators, or dot segments, with at most " +
187
+ `${PRODUCTION_IDENTIFIER_MAX_CHARS} characters`,
188
+ );
189
+ }
190
+ return null;
191
+ };
192
+
193
+ /**
194
+ * @param {object} pluginConfig `plugins.entries.<id>.config`, as OpenClaw hands it
195
+ * @param {object} env defaults to process.env
196
+ */
197
+ export function resolveConfig(pluginConfig = {}, env = process.env) {
198
+ const apiKey = firstString(pluginConfig.apiKey, env.PERCEPTEYE_API_KEY);
199
+ // THROWS on an unrecognised mode, and is meant to. A typo here decides
200
+ // whether this process claims rollouts on the customer's credentials, and
201
+ // there is no value to fall back to that is right in both directions.
202
+ const { mode, source: modeSource } = resolveModeSource(pluginConfig.mode, env);
203
+ return {
204
+ apiKey,
205
+ mode,
206
+ modeSource,
207
+ agentId: agentIdentifier(
208
+ pluginConfig.agentId, env.PERCEPTEYE_AGENT_ID,
209
+ ) ?? "openclaw",
210
+ // ONE NAME FOR ONE QUESTION. This also read an unprefixed
211
+ // `CONTROL_PLANE_ENV_URL`, at the LOWEST precedence -- while the Python
212
+ // SDK's harness recorder read the same variable at the HIGHEST. Nothing in
213
+ // the platform has ever set it, neither README named it, and it is not in
214
+ // `CONFIG_SCHEMA`, so no customer could discover it existed; what it could
215
+ // do was collide with an unrelated variable of that generic name in a
216
+ // customer's environment and send this agent's telemetry somewhere else.
217
+ // Two components of one product resolving the control plane differently is
218
+ // not something a precedence rule fixes -- it is a second answer, so it is
219
+ // gone rather than aligned.
220
+ controlPlaneUrl: firstString(
221
+ pluginConfig.controlPlaneUrl,
222
+ env.PERCEPTEYE_CONTROL_PLANE_URL,
223
+ ) ?? DEFAULT_CONTROL_PLANE,
224
+ // Capture is on unless DELIBERATELY disabled. A typo must not silently
225
+ // switch it off and leave the operator believing it is on.
226
+ captureEnabled: enabledUnlessOff(
227
+ pluginConfig.capture, env.PERCEPTEYE_CAPTURE,
228
+ ),
229
+ // The same rule for the same reason, and the same list of off-spellings --
230
+ // a customer who learned `capture=off` must not discover that
231
+ // `applyPrompt=off` was a typo that left the prompt being applied.
232
+ // Default ON: a capability that ships dormant is a capability nobody gets.
233
+ applyPromptEnabled: enabledUnlessOff(
234
+ pluginConfig.applyPrompt, env.PERCEPTEYE_APPLY_PROMPT,
235
+ ),
236
+ // The model half's own switch, mirroring the prompt one exactly -- same
237
+ // off-spellings, same precedence, same default. The two halves of a
238
+ // certified pair are separately refusable because they fail separately:
239
+ // an operator may want the approved prompt on their own provider, or the
240
+ // trained checkpoint under a prompt they wrote themselves.
241
+ applyModelEnabled: enabledUnlessOff(
242
+ pluginConfig.applyModel, env.PERCEPTEYE_APPLY_MODEL,
243
+ ),
244
+ // Adapter-owned opaque evidence. Validation and fail-closed reconciliation
245
+ // live with the identity code; configuration only transports the snapshot.
246
+ executionSnapshot: pluginConfig.executionSnapshot ?? null,
247
+ contributing: Boolean(apiKey),
248
+ };
249
+ }