@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/classify.js
ADDED
|
@@ -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
|
+
}
|