@percepteye/agent-flywheel 0.1.0 → 0.1.2
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/README.md +25 -5
- package/openclaw.plugin.json +22 -4
- package/package.json +1 -1
- package/src/capture.js +39 -33
- package/src/config.js +42 -3
- package/src/declaration.js +388 -0
- package/src/execution-identity.js +26 -1
- package/src/index.js +89 -6
- package/src/rollout.js +23 -6
package/README.md
CHANGED
|
@@ -37,15 +37,15 @@ One plugin. Zero runtime dependencies. Zero lines changed in your agent.
|
|
|
37
37
|
regardless of what CI did.
|
|
38
38
|
|
|
39
39
|
Separately: the `tests` badge below is hardcoded and nothing checks it;
|
|
40
|
-
its
|
|
41
|
-
grew). Re-derive with `npm test` (prints "tests
|
|
40
|
+
its 490 was true on 2026-09-15 (it had drifted to 373 while the suite
|
|
41
|
+
grew). Re-derive with `npm test` (prints "tests 490" / "skipped 0")
|
|
42
42
|
before trusting it — the Python SDK's equivalent badge had drifted to 54
|
|
43
43
|
short. -->
|
|
44
44
|
[](https://www.npmjs.com/package/@percepteye/agent-flywheel)
|
|
45
45
|
[](package.json)
|
|
46
46
|
[](#-why-it-earns-its-place)
|
|
47
47
|
[](#-install-in-one-call)
|
|
48
|
-
[](#-develop)
|
|
49
49
|
[](LICENSE)
|
|
50
50
|
|
|
51
51
|
<img src="docs/assets/flywheel-overview.gif"
|
|
@@ -70,7 +70,7 @@ No container to build. No cluster to run. No GPU to provision. No inbound port t
|
|
|
70
70
|
|:--|:--|
|
|
71
71
|
| 🔁 **A continual-learning loop** | your agent runs graded tasks, its real outcomes go back, and the next model is trained on *this agent's* work — not a generic benchmark |
|
|
72
72
|
| 🤖 **Autonomous post-training** | task generation, grading, dataset construction, training and promotion happen on our side. You build none of it |
|
|
73
|
-
| 🧪 **Honest discovery** | the plugin reads the assembled prompt and effective model; current OpenClaw does not expose tools on `llm_input`, so the catalogue stays explicitly incomplete instead of being invented |
|
|
73
|
+
| 🧪 **Honest discovery** | the plugin reads the assembled prompt and effective model; current OpenClaw does not expose tools on `llm_input`, so the catalogue stays explicitly incomplete instead of being invented — until you declare it with [`describe`](#-configuration) |
|
|
74
74
|
| 📈 **Honest telemetry on day one** | your agent's true tool-failure rate, recorded locally, with **no account and no network** |
|
|
75
75
|
| 📡 **Production observability** | capture the turns you actually serve — the user's message, the tool calls, the answer — and turn live traffic into evidence |
|
|
76
76
|
| ✅ **The approved pair, applied** | training co-optimizes a system prompt alongside the model. Once *a person on your side approves each*, your production install runs **both** — the prompt, and the checkpoint it was certified with. No redeploy; one switch each to stop |
|
|
@@ -319,9 +319,29 @@ Scalar settings are settable two ways: in your OpenClaw config under `plugins.en
|
|
|
319
319
|
| `applyPrompt` | `PERCEPTEYE_APPLY_PROMPT` | on | `0` subscribes no prompt hook and does not fetch the prompt text |
|
|
320
320
|
| `applyModel` | `PERCEPTEYE_APPLY_MODEL` | on | `0` subscribes no model hook and registers no provider |
|
|
321
321
|
| `executionSnapshot` | *(config only)* | *(none)* | opaque SHA-256 components from the host adapter, its completeness assertion, and optionally an exact startup prompt/tool/provider/model observation. Exact startup evidence binds registration and claims to the same SDK-authored execution identity later reported; missing, invalid, lossy, or conflicting evidence abstains |
|
|
322
|
+
| `introspect` | `PERCEPTEYE_INTROSPECT` | on | `0`/`false`/`no`/`off` registers this agent with no description at all, which the control plane records as your opt-out, and authors no execution identity. **The one switch where config does not win:** a refusal from either side stands, as it does in the Python SDK |
|
|
323
|
+
| `describe` | *(config only)* | *(none)* | the agent's own `system_prompt`, `tools` and `model`, for what this plugin cannot observe — OpenClaw's `llm_input` carries no tools. See below |
|
|
322
324
|
|
|
323
325
|
The snapshot contains `adapterName`, `executionIdentityComplete: true`, and a non-empty `executionComponents` object whose values are lowercase SHA-256 digests. An adapter that can resolve the full execution before work is claimed may also provide `startupObservation: {systemPrompt, tools, provider, model}`. Core runs startup and per-run evidence through the same canonical descriptor; it never accepts a caller-authored final digest. Component names belong to the adapter, and core does not assign them meaning. The resulting `execution_sha256` identifies the observed execution scaffold, not the serving policy, and this package does not generate training hints; those decisions belong downstream where evidence from multiple turns can be compared.
|
|
324
326
|
|
|
327
|
+
**`describe`** says what this plugin cannot see. OpenClaw's `llm_input` carries no tool list, so on its own this plugin registers an agent whose tools are unknown, and no task can be generated for it. Declare them:
|
|
328
|
+
|
|
329
|
+
```json
|
|
330
|
+
"plugins": { "entries": { "agent-flywheel": { "config": { "describe": {
|
|
331
|
+
"system_prompt": "You triage support tickets.",
|
|
332
|
+
"tools": [{ "name": "lookup_ticket", "description": "Read one ticket.",
|
|
333
|
+
"input_schema": { "type": "object", "properties": { "id": { "type": "string" } } },
|
|
334
|
+
"read_only": true }]
|
|
335
|
+
} } } } }
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
It is the Python SDK's `describe=`, read by the same rules, and a cross-language test holds the two equal: the same record, and the same refusals, for anything JSON carries — except an integer beyond 2⁵³ inside a schema, which JavaScript holds rounded.
|
|
339
|
+
- **`tools`** is the list your agent sends its model: OpenAI function specs, Anthropic tools, or `{name, description, input_schema, read_only}`. A name and a description must be strings. `read_only: true` goes at the top level of an entry, whichever shape.
|
|
340
|
+
- **`model`** is a name, or `{model_name, provider, temperature, max_tokens}`: `temperature` a finite number, and `max_tokens` a whole number from 1 to 2⁵³ − 1.
|
|
341
|
+
- **What you declare wins** over what was observed, where you gave it. Anything you leave out still comes from observation. With nothing observed, the record names this plugin's host, `framework: "openclaw"`, where the Python SDK — which cannot know — says `custom`.
|
|
342
|
+
- **It is your word, not an observation,** so it anchors no execution identity. If you also configured `executionSnapshot`, a startup warning says the identity is withheld.
|
|
343
|
+
- **A bad declaration does not stop the plugin**, because a throw would take local capture down with it — and the config schema accepts any `describe` value, so OpenClaw never refuses the plugin over one either. It is dropped with a warning that names the cause, the same one the Python SDK refuses with, and the agent registers only what was observed.
|
|
344
|
+
|
|
325
345
|
Three more environment variables name directories rather than switching anything. `PERCEPTEYE_HOME` relocates everything this package writes (default `~/.percepteye`); `PERCEPTEYE_CAPTURE_DIR` overrides just the production turn capture (default `$PERCEPTEYE_HOME/captured`); and `PERCEPTEYE_TRAJECTORY_DIR` names where a training rollout's outcomes go, which the rollout driver sets per rollout.
|
|
326
346
|
|
|
327
347
|
One host setting is not ours and has no environment form: `plugins.entries.agent-flywheel.hooks.allowConversationAccess`, which OpenClaw itself requires before it will deliver conversation hooks to a non-bundled plugin. See [Install](#-install-in-one-call).
|
|
@@ -358,7 +378,7 @@ One host setting is not ours and has no environment form: `plugins.entries.agent
|
|
|
358
378
|
## 🛠 Develop
|
|
359
379
|
|
|
360
380
|
```bash
|
|
361
|
-
npm test #
|
|
381
|
+
npm test # 490 tests, no install step — node:test, zero dependencies
|
|
362
382
|
```
|
|
363
383
|
|
|
364
384
|
The suite includes payloads captured verbatim from a live OpenClaw run (`test/host-fixtures.test.js`). If a future OpenClaw changes the shape, those fail — rather than someone discovering it months later from bad training data.
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "agent-flywheel",
|
|
3
3
|
"name": "Agent Flywheel",
|
|
4
4
|
"description": "Close the loop on your OpenClaw or DeepSeek Harness agent: record what your tools actually did — ok, failed or unknown — claim graded rollouts in training mode, and report them back. Never alters a tool result; set PERCEPTEYE_AGENT_MODE=production for a serving install, which claims no rollouts and starts no turn but does capture the turns you serve and apply the system prompt and the model your control plane has approved.",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.2",
|
|
6
6
|
"providers": [
|
|
7
7
|
"percepteye-flywheel"
|
|
8
8
|
],
|
|
@@ -30,21 +30,27 @@
|
|
|
30
30
|
"capture": {
|
|
31
31
|
"type": [
|
|
32
32
|
"string",
|
|
33
|
-
"boolean"
|
|
33
|
+
"boolean",
|
|
34
|
+
"number",
|
|
35
|
+
"null"
|
|
34
36
|
],
|
|
35
37
|
"description": "Set false/0/off to disable local capture entirely. Unset means PERCEPTEYE_CAPTURE, then on."
|
|
36
38
|
},
|
|
37
39
|
"applyPrompt": {
|
|
38
40
|
"type": [
|
|
39
41
|
"string",
|
|
40
|
-
"boolean"
|
|
42
|
+
"boolean",
|
|
43
|
+
"number",
|
|
44
|
+
"null"
|
|
41
45
|
],
|
|
42
46
|
"description": "Set false/0/off to stop applying the control plane's approved system prompt in production mode. Unset means PERCEPTEYE_APPLY_PROMPT, then on."
|
|
43
47
|
},
|
|
44
48
|
"applyModel": {
|
|
45
49
|
"type": [
|
|
46
50
|
"string",
|
|
47
|
-
"boolean"
|
|
51
|
+
"boolean",
|
|
52
|
+
"number",
|
|
53
|
+
"null"
|
|
48
54
|
],
|
|
49
55
|
"description": "Set false/0/off to stop pointing this agent at the control plane's approved model in production mode. Unset means PERCEPTEYE_APPLY_MODEL, then on. Needs hooks.allowConversationAccess=true to take effect at all."
|
|
50
56
|
},
|
|
@@ -125,6 +131,18 @@
|
|
|
125
131
|
"serving"
|
|
126
132
|
],
|
|
127
133
|
"description": "training: this install claims rollouts and drives turns. production: claims no rollout and starts no turn -- those hooks are never registered -- but captures the turns you serve and applies the system prompt your control plane approved. Unset means PERCEPTEYE_AGENT_MODE, then training; a value here overrides the environment."
|
|
134
|
+
},
|
|
135
|
+
"introspect": {
|
|
136
|
+
"type": [
|
|
137
|
+
"string",
|
|
138
|
+
"boolean",
|
|
139
|
+
"number",
|
|
140
|
+
"null"
|
|
141
|
+
],
|
|
142
|
+
"description": "Set false/0/off to register this agent with no description at all; the control plane records that as your opt-out. PERCEPTEYE_INTROSPECT=0 refuses too, whatever this says. Unset means on."
|
|
143
|
+
},
|
|
144
|
+
"describe": {
|
|
145
|
+
"description": "The agent's own description, for what this plugin cannot observe (OpenClaw's llm_input carries no tools): system_prompt, tools (the list the agent sends its model -- OpenAI function specs, Anthropic tools, or {name, description, input_schema, read_only}) and model (a name, or {model_name, provider, temperature, max_tokens}). What you declare wins where given; it anchors no execution identity."
|
|
128
146
|
}
|
|
129
147
|
}
|
|
130
148
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percepteye/agent-flywheel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Close the loop on your OpenClaw or DeepSeek Harness agent: record what your tools actually did — ok, failed or unknown — claim graded rollouts in training mode, and report them back. Never alters a tool result; set PERCEPTEYE_AGENT_MODE=production for a serving install, which claims no rollouts and starts no turn but does capture the turns you serve and apply the system prompt and the model your control plane has approved.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "PerceptEye",
|
package/src/capture.js
CHANGED
|
@@ -63,6 +63,8 @@ import { randomUUID } from "node:crypto";
|
|
|
63
63
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
64
64
|
import { join } from "node:path";
|
|
65
65
|
|
|
66
|
+
import { applyDeclaration } from "./declaration.js";
|
|
67
|
+
import { FRAMEWORK } from "./describe.js";
|
|
66
68
|
import { conversationAccessGranted, lastAssistantText } from "./host.js";
|
|
67
69
|
import { ControlPlaneClient, SDK_UA } from "./http.js";
|
|
68
70
|
import { percepteyeHome } from "./session.js";
|
|
@@ -471,7 +473,7 @@ export const GATEWAY_STOP = "gateway_stop";
|
|
|
471
473
|
*/
|
|
472
474
|
export function registerTurnCapture(api, {
|
|
473
475
|
config, turns, pluginId, logger = null, env = process.env,
|
|
474
|
-
makeTransport = defaultTransport,
|
|
476
|
+
makeTransport = defaultTransport, declaration = null,
|
|
475
477
|
}) {
|
|
476
478
|
if (!turns || typeof api?.on !== "function") return null;
|
|
477
479
|
const root = captureRoot(env);
|
|
@@ -555,7 +557,7 @@ export function registerTurnCapture(api, {
|
|
|
555
557
|
//
|
|
556
558
|
// NOT AWAITED: `register()` must return to the host synchronously, and a
|
|
557
559
|
// control plane that is slow or down must not delay a plugin load.
|
|
558
|
-
started = enrol({ transport, config, pluginId, logger })
|
|
560
|
+
started = enrol({ transport, config, pluginId, logger, declaration })
|
|
559
561
|
.then(() => announce({ transport, uploader, config, pluginId, logger, env }));
|
|
560
562
|
} else {
|
|
561
563
|
logger?.info?.(
|
|
@@ -606,34 +608,30 @@ function defaultTransport(config) {
|
|
|
606
608
|
* share one route, and only one of them survived the split. This is the other
|
|
607
609
|
* one, back as its own call, which is why `announce()` still performs no write.
|
|
608
610
|
*
|
|
609
|
-
* `discovered_agent
|
|
610
|
-
*
|
|
611
|
-
* different reasons:
|
|
611
|
+
* `discovered_agent` -- THREE ANSWERS, one per case, each the true one, asked
|
|
612
|
+
* exactly as the Python SDK's `attach()` asks them:
|
|
612
613
|
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
* generation. That is precisely the side effect `announce()` describes and
|
|
620
|
-
* the reason the old registration had to go, so re-creating it here would
|
|
621
|
-
* undo one fix while claiming another.
|
|
614
|
+
* OMITTED when the customer refused introspection (`introspect: false` or
|
|
615
|
+
* PERCEPTEYE_INTROSPECT=0). The control plane records that as `disabled`,
|
|
616
|
+
* which stops the stored description driving workflow generation -- the
|
|
617
|
+
* instruction the customer gave. Omitting it for anyone else would assert a
|
|
618
|
+
* decision nobody made, which is why this used to be the one state never
|
|
619
|
+
* sent: before the switch existed, nobody could have made it.
|
|
622
620
|
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
* this. Waiting for one would put registration behind a CONVERSATION hook
|
|
628
|
-
* the host drops for a non-bundled plugin by default, i.e. this same defect
|
|
629
|
-
* again for the majority of installs.
|
|
621
|
+
* THE DECLARATION, when the operator gave one (`describe`, see
|
|
622
|
+
* declaration.js): their word about the agent, and the only description
|
|
623
|
+
* this process can honestly send. The control plane keeps a stored
|
|
624
|
+
* catalogue that a declaration without tools did not observe.
|
|
630
625
|
*
|
|
631
|
-
* NULL
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
626
|
+
* NULL otherwise: we looked, and we have no description. `describe.js`, the
|
|
627
|
+
* only observer, writes into PERCEPTEYE_TRAJECTORY_DIR, which production
|
|
628
|
+
* mode does not set, and `llm_input` first fires on the first model call,
|
|
629
|
+
* strictly after this -- waiting for it would put registration behind a
|
|
630
|
+
* CONVERSATION hook the host drops for a non-bundled plugin by default. A
|
|
631
|
+
* production null changes nothing the control plane holds, neither the
|
|
632
|
+
* stored description nor its state (a new agent is `not_described`), so a
|
|
633
|
+
* process re-registering on every restart cannot erase what a training run
|
|
634
|
+
* established.
|
|
637
635
|
*
|
|
638
636
|
* NEVER REJECTS, and never throws into its caller. A refused or unreachable
|
|
639
637
|
* control plane must not cost the verdict read, local capture (which needs no
|
|
@@ -647,7 +645,7 @@ function defaultTransport(config) {
|
|
|
647
645
|
* package has already paid for one exported control-plane call that nothing
|
|
648
646
|
* called: `AttachTransport.register` itself, which is the defect above.
|
|
649
647
|
*/
|
|
650
|
-
function enrol({ transport, config, pluginId, logger = null }) {
|
|
648
|
+
function enrol({ transport, config, pluginId, logger = null, declaration = null }) {
|
|
651
649
|
return Promise.resolve()
|
|
652
650
|
.then(() => transport.register({
|
|
653
651
|
agent_id: config.agentId,
|
|
@@ -673,8 +671,15 @@ function enrol({ transport, config, pluginId, logger = null }) {
|
|
|
673
671
|
concurrency: 1,
|
|
674
672
|
input_shape: "text",
|
|
675
673
|
entrypoint: "openclaw-plugin",
|
|
676
|
-
// See the docstring
|
|
677
|
-
|
|
674
|
+
// See the docstring: omitted on a refusal, the declaration when there
|
|
675
|
+
// is one, and otherwise null -- never invented. Last, where it always
|
|
676
|
+
// was, so an undeclared registration is the same bytes as before. The
|
|
677
|
+
// framework is the host's: this plugin knows it runs in OpenClaw.
|
|
678
|
+
...(config.introspect === false ? {} : {
|
|
679
|
+
discovered_agent: applyDeclaration(declaration, {
|
|
680
|
+
reflected: null, agentName: config.agentId, framework: FRAMEWORK,
|
|
681
|
+
}) ?? null,
|
|
682
|
+
}),
|
|
678
683
|
}))
|
|
679
684
|
.then(() => true)
|
|
680
685
|
.catch((err) => {
|
|
@@ -706,9 +711,10 @@ function enrol({ transport, config, pluginId, logger = null }) {
|
|
|
706
711
|
* writes -- re-asking costs a read and nothing else, which is the whole point
|
|
707
712
|
* of the route -- but the row the verdict is computed from still has to be
|
|
708
713
|
* created by somebody, and `enrol()` above is now the one that does it. When
|
|
709
|
-
* that was left out, this read answered "not registered" forever.
|
|
710
|
-
* `discovered_agent
|
|
711
|
-
*
|
|
714
|
+
* that was left out, this read answered "not registered" forever. What it
|
|
715
|
+
* sends as `discovered_agent` -- null, a declaration, or nothing under the
|
|
716
|
+
* customer's opt-out -- is no longer a workaround for a side effect: each is
|
|
717
|
+
* the honest state, for the reasons `enrol()` gives.
|
|
712
718
|
*/
|
|
713
719
|
export function announce({ transport, uploader, config, pluginId, logger }) {
|
|
714
720
|
return Promise.resolve()
|
package/src/config.js
CHANGED
|
@@ -47,6 +47,12 @@ export const PLUGIN_ID = "agent-flywheel";
|
|
|
47
47
|
* rollouts and started unattended turns anyway, and the operator got no signal
|
|
48
48
|
* because the production-mode log is on the branch that was never taken.
|
|
49
49
|
*
|
|
50
|
+
* THE SAME HOST REFUSES A WRONG TYPE THE SAME WAY: a value its validator
|
|
51
|
+
* rejects fails the plugin load. So the on/off switches take a NUMBER too --
|
|
52
|
+
* `capture: 0` is documented as off, and a schema of string|boolean refused it
|
|
53
|
+
* and skipped the plugin -- and NULL, which `resolveConfig` reads as unset;
|
|
54
|
+
* and `describe` declares no type at all.
|
|
55
|
+
*
|
|
50
56
|
* `resolveConfig` below is the ONE place a default is decided. A schema default
|
|
51
57
|
* is a second answer to a question already answered there, and this host turns
|
|
52
58
|
* it into the winning one. `test/config-gate.test.js` asserts the absence.
|
|
@@ -77,20 +83,20 @@ export const CONFIG_SCHEMA = {
|
|
|
77
83
|
description: "Override the control-plane base URL.",
|
|
78
84
|
},
|
|
79
85
|
capture: {
|
|
80
|
-
type: ["string", "boolean"],
|
|
86
|
+
type: ["string", "boolean", "number", "null"],
|
|
81
87
|
description:
|
|
82
88
|
"Set false/0/off to disable local capture entirely. Unset means " +
|
|
83
89
|
"PERCEPTEYE_CAPTURE, then on.",
|
|
84
90
|
},
|
|
85
91
|
applyPrompt: {
|
|
86
|
-
type: ["string", "boolean"],
|
|
92
|
+
type: ["string", "boolean", "number", "null"],
|
|
87
93
|
description:
|
|
88
94
|
"Set false/0/off to stop applying the control plane's approved " +
|
|
89
95
|
"system prompt in production mode. Unset means " +
|
|
90
96
|
"PERCEPTEYE_APPLY_PROMPT, then on.",
|
|
91
97
|
},
|
|
92
98
|
applyModel: {
|
|
93
|
-
type: ["string", "boolean"],
|
|
99
|
+
type: ["string", "boolean", "number", "null"],
|
|
94
100
|
description:
|
|
95
101
|
"Set false/0/off to stop pointing this agent at the control " +
|
|
96
102
|
"plane's approved model in production mode. Unset means " +
|
|
@@ -150,6 +156,27 @@ export const CONFIG_SCHEMA = {
|
|
|
150
156
|
"PERCEPTEYE_AGENT_MODE, then training; a value here overrides the " +
|
|
151
157
|
"environment.",
|
|
152
158
|
},
|
|
159
|
+
introspect: {
|
|
160
|
+
type: ["string", "boolean", "number", "null"],
|
|
161
|
+
description:
|
|
162
|
+
"Set false/0/off to register this agent with no description at all; " +
|
|
163
|
+
"the control plane records that as your opt-out. " +
|
|
164
|
+
"PERCEPTEYE_INTROSPECT=0 refuses too, whatever this says. Unset " +
|
|
165
|
+
"means on.",
|
|
166
|
+
},
|
|
167
|
+
// NO `type`, deliberately. A value the host's validator refuses fails the
|
|
168
|
+
// whole plugin load and takes local capture down with it, so any value
|
|
169
|
+
// passes here and declaration.js decides -- refusing a bad one with a
|
|
170
|
+
// warning, the plugin still loaded.
|
|
171
|
+
describe: {
|
|
172
|
+
description:
|
|
173
|
+
"The agent's own description, for what this plugin cannot observe " +
|
|
174
|
+
"(OpenClaw's llm_input carries no tools): system_prompt, tools (the " +
|
|
175
|
+
"list the agent sends its model -- OpenAI function specs, Anthropic " +
|
|
176
|
+
"tools, or {name, description, input_schema, read_only}) and model " +
|
|
177
|
+
"(a name, or {model_name, provider, temperature, max_tokens}). What " +
|
|
178
|
+
"you declare wins where given; it anchors no execution identity.",
|
|
179
|
+
},
|
|
153
180
|
},
|
|
154
181
|
};
|
|
155
182
|
|
|
@@ -244,6 +271,18 @@ export function resolveConfig(pluginConfig = {}, env = process.env) {
|
|
|
244
271
|
// Adapter-owned opaque evidence. Validation and fail-closed reconciliation
|
|
245
272
|
// live with the identity code; configuration only transports the snapshot.
|
|
246
273
|
executionSnapshot: pluginConfig.executionSnapshot ?? null,
|
|
274
|
+
// CONSENT to describe this agent, asked as the Python SDK asks it and
|
|
275
|
+
// refusable from EITHER side: `introspect: false` here (Python's
|
|
276
|
+
// `serve(introspect=False)`) or PERCEPTEYE_INTROSPECT=0 (for an operator
|
|
277
|
+
// who cannot edit the config). Unlike the switches above, neither side
|
|
278
|
+
// overrides the other -- one refusal is enough, as it is in Python.
|
|
279
|
+
introspect:
|
|
280
|
+
enabledUnlessOff(pluginConfig.introspect, undefined) &&
|
|
281
|
+
enabledUnlessOff(undefined, env.PERCEPTEYE_INTROSPECT),
|
|
282
|
+
// The operator's own description, transported only. It is checked in
|
|
283
|
+
// index.js, where a bad one can be refused loudly without failing the
|
|
284
|
+
// plugin load; see declaration.js.
|
|
285
|
+
describe: pluginConfig.describe ?? null,
|
|
247
286
|
contributing: Boolean(apiKey),
|
|
248
287
|
};
|
|
249
288
|
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `describe`: an agent describing itself when nothing can read it.
|
|
3
|
+
*
|
|
4
|
+
* The Node end of the Python SDK's `agent_flywheel/declaration.py`, rule for
|
|
5
|
+
* rule, so a declaration registers the same description from either SDK. The
|
|
6
|
+
* Python repository's `tests/test_describe_parity.py` runs this module beside
|
|
7
|
+
* that one over one table and holds them equal: the same wire record, and the
|
|
8
|
+
* same refusals.
|
|
9
|
+
*
|
|
10
|
+
* WHY THE NODE PLUGIN NEEDS IT. OpenClaw's `llm_input` carries no tools, so
|
|
11
|
+
* this plugin's own observation of an agent never has a catalogue (see
|
|
12
|
+
* `describe.js`), and a catalogue is what task generation needs. The operator
|
|
13
|
+
* knows what the agent sends its model; this is where they say it:
|
|
14
|
+
*
|
|
15
|
+
* plugins.entries.agent-flywheel.config.describe = {
|
|
16
|
+
* system_prompt: "...",
|
|
17
|
+
* tools: [ ...the tool list the agent sends its model ],
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* THE CUSTOMER'S WORD WINS, AND ONLY WHERE THEY GAVE IT. Declared fields
|
|
21
|
+
* replace what was observed; anything left out still comes from observation.
|
|
22
|
+
* Leaving `tools` out never means "no tools" -- that is `tools: []`.
|
|
23
|
+
*
|
|
24
|
+
* PYTHON'S PARSER, NOT `normalizeTool`. The host observation's parser reads
|
|
25
|
+
* OpenClaw shapes and scrubs what it copies. A declaration is read exactly as
|
|
26
|
+
* the Python SDK reads it -- OpenAI function specs, Anthropic tools, or the
|
|
27
|
+
* protocol's own `{name, description, input_schema, read_only}` -- because
|
|
28
|
+
* the one thing that matters about a declared tool is that both SDKs register
|
|
29
|
+
* the same record for it. That holds for anything JSON carries, with one
|
|
30
|
+
* limit JavaScript imposes: an integer beyond 2**53 inside a schema is held
|
|
31
|
+
* rounded here. Where the two languages would otherwise part -- whitespace,
|
|
32
|
+
* coercion, number types, sort order -- the rule is Python's, or is one both
|
|
33
|
+
* can keep: names and descriptions must be strings, and `max_tokens` a whole
|
|
34
|
+
* number both hold exactly.
|
|
35
|
+
*
|
|
36
|
+
* NOT EVIDENCE. A declaration is what the customer says the agent does, not an
|
|
37
|
+
* observation of what ran, so a declared description anchors no execution
|
|
38
|
+
* identity: `index.js` withholds the fingerprint whenever this module
|
|
39
|
+
* contributed.
|
|
40
|
+
*/
|
|
41
|
+
import { ConfigurationError } from "./errors.js";
|
|
42
|
+
import { compareStrings } from "./execution-identity.js";
|
|
43
|
+
|
|
44
|
+
/** `source_type` of a description a declaration contributed to. */
|
|
45
|
+
export const SOURCE_TYPE = "sdk_declared";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The registration projection's prompt bound: the Python SDK's
|
|
49
|
+
* `_MAX_PROMPT_CHARS`, counted in code points as Python counts them.
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_PROMPT_CHARS = 20_000;
|
|
52
|
+
|
|
53
|
+
const KEYS = ["model", "system_prompt", "tools"];
|
|
54
|
+
const MODEL_KEYS = ["max_tokens", "model_name", "provider", "temperature"];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Python's `str.strip()`: exactly the characters `str.isspace()` names. JS's
|
|
58
|
+
* `trim()` also strips U+FEFF and keeps U+001C-U+001F and U+0085, so a tool
|
|
59
|
+
* named "\u0085" was a tool here and nothing in Python.
|
|
60
|
+
*/
|
|
61
|
+
const PY_SPACE = "\\t\\n\\v\\f\\r\\x1c-\\x20\\x85\\xa0\\u1680\\u2000-\\u200a" +
|
|
62
|
+
"\\u2028\\u2029\\u202f\\u205f\\u3000";
|
|
63
|
+
const PY_STRIP = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, "g");
|
|
64
|
+
const strip = (text) => text.replace(PY_STRIP, "");
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Python's truthiness, where the Python end branches on it: an empty array or
|
|
68
|
+
* object is FALSE there and true here, and a declaration must not turn into a
|
|
69
|
+
* different tool on the Node side because of it.
|
|
70
|
+
*/
|
|
71
|
+
function truthy(v) {
|
|
72
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
73
|
+
if (isMapping(v)) return Object.keys(v).length > 0;
|
|
74
|
+
return Boolean(v);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A JSON object: Python's `Mapping`. A PLAIN object only -- a Promise (an
|
|
79
|
+
* async `describe`), a Map or a class instance is not a declaration, and
|
|
80
|
+
* reading one as an empty object declared nothing in silence.
|
|
81
|
+
*/
|
|
82
|
+
function isMapping(v) {
|
|
83
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
|
84
|
+
const proto = Object.getPrototypeOf(v);
|
|
85
|
+
return proto === Object.prototype || proto === null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The JSON type a refusal names: one vocabulary both SDKs print alike. */
|
|
89
|
+
function jsonType(v) {
|
|
90
|
+
if (v === null || v === undefined) return "null";
|
|
91
|
+
if (typeof v === "boolean") return "boolean";
|
|
92
|
+
if (typeof v === "number") return "number";
|
|
93
|
+
if (typeof v === "string") return "string";
|
|
94
|
+
if (Array.isArray(v)) return "array";
|
|
95
|
+
if (isMapping(v)) return "object";
|
|
96
|
+
if (typeof v !== "object") return typeof v;
|
|
97
|
+
try {
|
|
98
|
+
return v.constructor?.name ?? "object";
|
|
99
|
+
} catch {
|
|
100
|
+
return "object";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Python's `json.dumps(str)`: `JSON.stringify` with non-ASCII escaped. */
|
|
105
|
+
function jsonAscii(text) {
|
|
106
|
+
return JSON.stringify(text).replace(/[\u0080-\uffff]/g,
|
|
107
|
+
(c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Key names as a JSON array, sorted by code point, as Python renders them. */
|
|
111
|
+
function keyList(keys) {
|
|
112
|
+
return `[${[...keys].sort(compareStrings).map(jsonAscii).join(", ")}]`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A value quoted back in a refusal. Never throws: a BigInt or a cycle is still a refusal. */
|
|
116
|
+
function render(v) {
|
|
117
|
+
let text;
|
|
118
|
+
try {
|
|
119
|
+
text = JSON.stringify(v);
|
|
120
|
+
} catch {
|
|
121
|
+
text = undefined;
|
|
122
|
+
}
|
|
123
|
+
if (text === undefined) {
|
|
124
|
+
try {
|
|
125
|
+
text = String(v);
|
|
126
|
+
} catch {
|
|
127
|
+
text = Object.prototype.toString.call(v);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return text.slice(0, 120);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* `describe` validated and parsed, calling it if it is a function.
|
|
135
|
+
*
|
|
136
|
+
* `null` when nothing was declared -- including an object whose every value
|
|
137
|
+
* is null. A function that throws is refused the same way, naming `describe`.
|
|
138
|
+
*
|
|
139
|
+
* @returns {{system_prompt: string|null, tools: object[]|null, model: object|null}|null}
|
|
140
|
+
* @throws {ConfigurationError}
|
|
141
|
+
*/
|
|
142
|
+
export function resolveDeclaration(describe) {
|
|
143
|
+
if (describe === null || describe === undefined) return null;
|
|
144
|
+
let spec;
|
|
145
|
+
if (typeof describe === "function") {
|
|
146
|
+
try {
|
|
147
|
+
spec = describe();
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (err instanceof ConfigurationError) throw err;
|
|
150
|
+
// Whatever was thrown -- a Symbol, a null-prototype object -- becomes a
|
|
151
|
+
// refusal; reading its name or message must not throw a second time.
|
|
152
|
+
let name;
|
|
153
|
+
let detail;
|
|
154
|
+
try {
|
|
155
|
+
name = err?.constructor?.name ?? typeof err;
|
|
156
|
+
detail = typeof err?.message === "string" ? err.message : render(err);
|
|
157
|
+
} catch {
|
|
158
|
+
name = typeof err;
|
|
159
|
+
detail = render(err);
|
|
160
|
+
}
|
|
161
|
+
throw new ConfigurationError(`describe= raised ${name}: ${detail}`);
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
spec = describe;
|
|
165
|
+
}
|
|
166
|
+
if (!isMapping(spec)) {
|
|
167
|
+
throw new ConfigurationError(
|
|
168
|
+
"describe= must be a mapping (or a callable returning one), got " +
|
|
169
|
+
jsonType(spec));
|
|
170
|
+
}
|
|
171
|
+
const unknown = Object.keys(spec).filter((k) => !KEYS.includes(k));
|
|
172
|
+
if (unknown.length) {
|
|
173
|
+
throw new ConfigurationError(
|
|
174
|
+
`describe= got unknown key(s) ${keyList(unknown)}; it accepts ` +
|
|
175
|
+
keyList(KEYS));
|
|
176
|
+
}
|
|
177
|
+
const prompt = spec.system_prompt ?? null;
|
|
178
|
+
const tools = spec.tools ?? null;
|
|
179
|
+
const model = spec.model ?? null;
|
|
180
|
+
if (prompt !== null && typeof prompt !== "string") {
|
|
181
|
+
throw new ConfigurationError("describe['system_prompt'] must be a string");
|
|
182
|
+
}
|
|
183
|
+
if (tools !== null && !Array.isArray(tools)) {
|
|
184
|
+
throw new ConfigurationError(
|
|
185
|
+
"describe['tools'] must be a list -- the tools you send the model");
|
|
186
|
+
}
|
|
187
|
+
if (model !== null && typeof model !== "string" && !isMapping(model)) {
|
|
188
|
+
throw new ConfigurationError(
|
|
189
|
+
"describe['model'] must be a model name or a mapping of " +
|
|
190
|
+
"provider / model_name / temperature / max_tokens");
|
|
191
|
+
}
|
|
192
|
+
if (prompt === null && tools === null && model === null) return null;
|
|
193
|
+
return {
|
|
194
|
+
system_prompt: prompt === null ? null : codePoints(prompt, MAX_PROMPT_CHARS),
|
|
195
|
+
tools: tools === null ? null : declaredTools(tools),
|
|
196
|
+
model: model === null ? null : declaredModel(model),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** `text` cut to `max` code points, as a Python slice cuts it. */
|
|
201
|
+
function codePoints(text, max) {
|
|
202
|
+
if (text.length <= max) return text;
|
|
203
|
+
return Array.from(text).slice(0, max).join("");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Python's `_tools_from_request` for one entry: the OpenAI envelope, or a flat
|
|
208
|
+
* tool with a name. The legacy flat `functions` shape spells the schema
|
|
209
|
+
* `parameters`, so that is read when `input_schema` is absent.
|
|
210
|
+
*/
|
|
211
|
+
function fromRequest(entry) {
|
|
212
|
+
if (!isMapping(entry)) return null;
|
|
213
|
+
const fn = entry.function;
|
|
214
|
+
if (isMapping(fn)) {
|
|
215
|
+
return {
|
|
216
|
+
name: fn.name, description: fn.description,
|
|
217
|
+
input_schema: fn.parameters, original_format: "openai_function",
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (truthy(entry.name)) {
|
|
221
|
+
return {
|
|
222
|
+
name: entry.name, description: entry.description,
|
|
223
|
+
input_schema: Object.hasOwn(entry, "input_schema")
|
|
224
|
+
? entry.input_schema : entry.parameters,
|
|
225
|
+
original_format: "anthropic_tool",
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Python's `_tool_from_wire`: null when there is no usable name. */
|
|
232
|
+
function fromWire(raw) {
|
|
233
|
+
const name = strip(truthy(raw.name) ? raw.name : "");
|
|
234
|
+
if (!name) return null;
|
|
235
|
+
return {
|
|
236
|
+
name,
|
|
237
|
+
description: truthy(raw.description) ? raw.description : "",
|
|
238
|
+
input_schema: isMapping(raw.input_schema) ? raw.input_schema : {},
|
|
239
|
+
original_format: raw.original_format,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The declared tools, in the wire's tool shape. A tool the parser cannot read
|
|
245
|
+
* is the customer's mistake and is said at startup rather than dropped: a
|
|
246
|
+
* silently shorter catalogue is a smaller task universe nobody chose. So is a
|
|
247
|
+
* schema that is not a mapping, a `read_only` that is not a boolean or sits
|
|
248
|
+
* inside `function` where the parser would not see it, and a name or
|
|
249
|
+
* description that is not a string -- coerced, it became text no two
|
|
250
|
+
* languages agree on.
|
|
251
|
+
*/
|
|
252
|
+
function declaredTools(declared) {
|
|
253
|
+
return declared.map((entry, i) => {
|
|
254
|
+
const where = `describe['tools'][${i}]`;
|
|
255
|
+
if (isMapping(entry)) {
|
|
256
|
+
const body = isMapping(entry.function) ? entry.function : entry;
|
|
257
|
+
for (const field of ["name", "description"]) {
|
|
258
|
+
const value = body[field] ?? null;
|
|
259
|
+
if (value !== null && typeof value !== "string") {
|
|
260
|
+
throw new ConfigurationError(
|
|
261
|
+
`${where}: '${field}' must be a string, got ${jsonType(value)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const parsed = fromRequest(entry);
|
|
266
|
+
const tool = parsed === null ? null : fromWire(parsed);
|
|
267
|
+
if (tool === null) {
|
|
268
|
+
throw new ConfigurationError(
|
|
269
|
+
`${where} is not a tool: expected an OpenAI function spec, an ` +
|
|
270
|
+
`Anthropic tool, or a mapping with a 'name', got ${render(entry)}`);
|
|
271
|
+
}
|
|
272
|
+
const schema = parsed.input_schema;
|
|
273
|
+
if (schema !== null && schema !== undefined && !isMapping(schema)) {
|
|
274
|
+
throw new ConfigurationError(
|
|
275
|
+
`${where}: the schema must be a JSON-schema mapping, got ` +
|
|
276
|
+
jsonType(schema));
|
|
277
|
+
}
|
|
278
|
+
if (isMapping(entry.function) && Object.hasOwn(entry.function, "read_only")) {
|
|
279
|
+
throw new ConfigurationError(
|
|
280
|
+
`${where}: read_only goes beside 'function', not inside it`);
|
|
281
|
+
}
|
|
282
|
+
const readOnly = entry.read_only ?? null;
|
|
283
|
+
if (readOnly !== null && typeof readOnly !== "boolean") {
|
|
284
|
+
throw new ConfigurationError(
|
|
285
|
+
`${where}['read_only'] must be true or false, got ${render(readOnly)}`);
|
|
286
|
+
}
|
|
287
|
+
// OMITTED when undeclared, never null: the Python wire's absence rule.
|
|
288
|
+
return readOnly === null ? tool : { ...tool, read_only: readOnly };
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The declared model, in the wire's model-config shape. */
|
|
293
|
+
function declaredModel(declared) {
|
|
294
|
+
if (typeof declared === "string") {
|
|
295
|
+
if (!strip(declared)) {
|
|
296
|
+
throw new ConfigurationError("describe['model'] must not be empty");
|
|
297
|
+
}
|
|
298
|
+
return { provider: "unknown", model_name: declared };
|
|
299
|
+
}
|
|
300
|
+
const unknown = Object.keys(declared).filter((k) => !MODEL_KEYS.includes(k));
|
|
301
|
+
if (unknown.length) {
|
|
302
|
+
throw new ConfigurationError(
|
|
303
|
+
`describe['model'] got unknown key(s) ${keyList(unknown)}; it accepts ` +
|
|
304
|
+
keyList(MODEL_KEYS));
|
|
305
|
+
}
|
|
306
|
+
const name = declared.model_name ?? null;
|
|
307
|
+
const provider = declared.provider ?? null;
|
|
308
|
+
if (typeof name !== "string" || !strip(name)) {
|
|
309
|
+
throw new ConfigurationError("describe['model'] needs a 'model_name' string");
|
|
310
|
+
}
|
|
311
|
+
if (provider !== null && typeof provider !== "string") {
|
|
312
|
+
throw new ConfigurationError("describe['model']['provider'] must be a string");
|
|
313
|
+
}
|
|
314
|
+
const out = { provider: provider || "unknown", model_name: name };
|
|
315
|
+
const temperature = declaredTemperature(declared.temperature ?? null);
|
|
316
|
+
const maxTokens = declaredMaxTokens(declared.max_tokens ?? null);
|
|
317
|
+
if (temperature !== null) out.temperature = temperature;
|
|
318
|
+
if (maxTokens !== null) out.max_tokens = maxTokens;
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A finite number, or null: `1e400` reads as Infinity, which is not JSON. */
|
|
323
|
+
function declaredTemperature(value) {
|
|
324
|
+
if (value === null) return null;
|
|
325
|
+
if (typeof value !== "number") {
|
|
326
|
+
throw new ConfigurationError("describe['model']['temperature'] must be a number");
|
|
327
|
+
}
|
|
328
|
+
if (!Number.isFinite(value)) {
|
|
329
|
+
throw new ConfigurationError(
|
|
330
|
+
"describe['model']['temperature'] must be a finite number");
|
|
331
|
+
}
|
|
332
|
+
return value;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** A whole number from 1 to 2**53 - 1, or null: past that, JSON reads round. */
|
|
336
|
+
function declaredMaxTokens(value) {
|
|
337
|
+
if (value === null) return null;
|
|
338
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
339
|
+
throw new ConfigurationError(
|
|
340
|
+
"describe['model']['max_tokens'] must be a whole number from 1 to " +
|
|
341
|
+
Number.MAX_SAFE_INTEGER);
|
|
342
|
+
}
|
|
343
|
+
return value;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* `reflected` (a wire description) with the declared fields in place, the
|
|
348
|
+
* declaration alone when nothing was observed, or `reflected` unchanged when
|
|
349
|
+
* nothing was declared.
|
|
350
|
+
*
|
|
351
|
+
* `framework` names what the caller knows when nothing was observed. The
|
|
352
|
+
* Python SDK knows nothing about a custom agent and says `custom`; this plugin
|
|
353
|
+
* knows its host, and passes it.
|
|
354
|
+
*
|
|
355
|
+
* A part the declaration supplies stops being listed in `incomplete`: it is no
|
|
356
|
+
* longer unknown.
|
|
357
|
+
*/
|
|
358
|
+
export function applyDeclaration(
|
|
359
|
+
declared, { reflected = null, agentName, framework = "custom" },
|
|
360
|
+
) {
|
|
361
|
+
if (declared === null || declared === undefined) return reflected;
|
|
362
|
+
const base = reflected ?? {
|
|
363
|
+
agent_name: agentName,
|
|
364
|
+
framework,
|
|
365
|
+
source_type: "sdk_runtime",
|
|
366
|
+
role: "standalone",
|
|
367
|
+
tool_definitions: null,
|
|
368
|
+
};
|
|
369
|
+
const out = { ...base, source_type: SOURCE_TYPE };
|
|
370
|
+
const supplied = new Set();
|
|
371
|
+
if (declared.system_prompt !== null) {
|
|
372
|
+
out.system_prompt = declared.system_prompt;
|
|
373
|
+
supplied.add("system_prompt");
|
|
374
|
+
}
|
|
375
|
+
if (declared.tools !== null) {
|
|
376
|
+
out.tool_definitions = declared.tools.map((t) => ({ ...t }));
|
|
377
|
+
supplied.add("tool_definitions").add("tool");
|
|
378
|
+
}
|
|
379
|
+
if (declared.model !== null) {
|
|
380
|
+
out.discovered_model_config = { ...declared.model };
|
|
381
|
+
supplied.add("discovered_model_config");
|
|
382
|
+
}
|
|
383
|
+
const incomplete = (Array.isArray(base.incomplete) ? base.incomplete : [])
|
|
384
|
+
.filter((part) => !supplied.has(part));
|
|
385
|
+
if (incomplete.length) out.incomplete = incomplete;
|
|
386
|
+
else delete out.incomplete;
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
@@ -40,7 +40,8 @@ function freezeJsonTree(value, seen = new Set()) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/** Unicode-code-point ordering, matching Python rather than UTF-16 sort. */
|
|
43
|
-
|
|
43
|
+
/** Order by code point, as Python's `sorted()` orders `str`; not by UTF-16 unit. */
|
|
44
|
+
export function compareStrings(left, right) {
|
|
44
45
|
const a = [...left].map((c) => c.codePointAt(0));
|
|
45
46
|
const b = [...right].map((c) => c.codePointAt(0));
|
|
46
47
|
for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
|
|
@@ -442,3 +443,27 @@ export function createExecutionIdentityTracker({
|
|
|
442
443
|
get refusal() { return normalized.reason; },
|
|
443
444
|
};
|
|
444
445
|
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* `tracker` with every fingerprint withheld and its startup observation kept.
|
|
449
|
+
*
|
|
450
|
+
* For a DECLARED description (`describe`, see declaration.js): the declaration
|
|
451
|
+
* still merges over what was observed at startup, but it is the customer's
|
|
452
|
+
* word rather than an observation of what runs, so it anchors no execution
|
|
453
|
+
* identity -- not at registration, not on a rollout report, not on a
|
|
454
|
+
* production turn. The Python SDK withholds its digest the same way whenever
|
|
455
|
+
* `describe=` contributed.
|
|
456
|
+
*/
|
|
457
|
+
export function withholdFingerprints(tracker) {
|
|
458
|
+
return {
|
|
459
|
+
observe: () => null,
|
|
460
|
+
fingerprintFor: () => null,
|
|
461
|
+
consumeFingerprintFor: () => null,
|
|
462
|
+
get registrationFingerprint() { return null; },
|
|
463
|
+
get registrationDescription() { return tracker.registrationDescription; },
|
|
464
|
+
get enabled() { return false; },
|
|
465
|
+
get refusal() {
|
|
466
|
+
return "the description is declared with describe, not observed";
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
}
|
package/src/index.js
CHANGED
|
@@ -84,12 +84,68 @@ import { conversationAccessGranted } from "./host.js";
|
|
|
84
84
|
import {
|
|
85
85
|
captureRoot, createTurnCapture, registerTurnCapture,
|
|
86
86
|
} from "./capture.js";
|
|
87
|
-
import {
|
|
87
|
+
import {
|
|
88
|
+
createExecutionIdentityTracker, withholdFingerprints,
|
|
89
|
+
} from "./execution-identity.js";
|
|
90
|
+
import { resolveDeclaration } from "./declaration.js";
|
|
88
91
|
|
|
89
92
|
export const PLUGIN_ID = "agent-flywheel";
|
|
90
93
|
export const HOOK_NAME = "after_tool_call";
|
|
91
94
|
export { DESCRIBE_HOOK_NAME };
|
|
92
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The operator's `describe`, validated -- or null, having said why.
|
|
98
|
+
*
|
|
99
|
+
* NOT A THROW, and that is the one place this differs from the Python SDK,
|
|
100
|
+
* which refuses to start on a bad declaration. Here a throw fails the plugin
|
|
101
|
+
* load and takes local capture down with it (see the mode fallback below), so
|
|
102
|
+
* a bad declaration is dropped with a warning that names the same cause
|
|
103
|
+
* Python's refusal names, and the agent registers what was observed.
|
|
104
|
+
*
|
|
105
|
+
* A STATIC declaration is checked whatever consent says, as Python checks it:
|
|
106
|
+
* lifting the opt-out later must not be the moment a typo surfaces. A
|
|
107
|
+
* FUNCTION runs only with consent -- nothing of the customer's runs for a
|
|
108
|
+
* description that will not be sent.
|
|
109
|
+
*
|
|
110
|
+
* Anything thrown is caught, not only a ConfigurationError: this runs inside
|
|
111
|
+
* `register()`, and the promise is that a declaration never costs the load.
|
|
112
|
+
*/
|
|
113
|
+
function declarationFor(config, logger) {
|
|
114
|
+
if (config.describe === null) return null;
|
|
115
|
+
const refusal = (err) => (err instanceof ConfigurationError
|
|
116
|
+
? err.message
|
|
117
|
+
: `describe could not be read (${err?.message ?? typeof err})`);
|
|
118
|
+
if (!config.introspect) {
|
|
119
|
+
if (typeof config.describe !== "function") {
|
|
120
|
+
try {
|
|
121
|
+
resolveDeclaration(config.describe);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
logger?.warn?.(
|
|
124
|
+
`[${PLUGIN_ID}] ${refusal(err)}. Fix ` +
|
|
125
|
+
`plugins.entries.${PLUGIN_ID}.config.describe before turning ` +
|
|
126
|
+
`introspection back on.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
logger?.warn?.(
|
|
131
|
+
`[${PLUGIN_ID}] introspection is off (introspect / ` +
|
|
132
|
+
`PERCEPTEYE_INTROSPECT), and it wins: the description declared with ` +
|
|
133
|
+
`describe is not sent, and this agent registers no description at all.`,
|
|
134
|
+
);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return resolveDeclaration(config.describe);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
logger?.warn?.(
|
|
141
|
+
`[${PLUGIN_ID}] ${refusal(err)}. The declaration is ignored, so this ` +
|
|
142
|
+
`agent registers only what was observed. Fix ` +
|
|
143
|
+
`plugins.entries.${PLUGIN_ID}.config.describe.`,
|
|
144
|
+
);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
93
149
|
|
|
94
150
|
export function register(api, pluginConfig = {}, env = process.env) {
|
|
95
151
|
if (typeof api?.on !== "function") return null;
|
|
@@ -164,18 +220,44 @@ export function register(api, pluginConfig = {}, env = process.env) {
|
|
|
164
220
|
);
|
|
165
221
|
return null;
|
|
166
222
|
}
|
|
167
|
-
|
|
168
|
-
|
|
223
|
+
// What the operator says this agent is (see declaration.js), and whether
|
|
224
|
+
// they consented to saying anything at all.
|
|
225
|
+
const declaration = declarationFor(config, logger);
|
|
226
|
+
const tracker = createExecutionIdentityTracker({
|
|
227
|
+
// Refused discovery authors no identity: the Python SDK authors none when
|
|
228
|
+
// PERCEPTEYE_INTROSPECT is off. (The describer below may still write
|
|
229
|
+
// discovered_agent.json locally under PERCEPTEYE_TRAJECTORY_DIR; its one
|
|
230
|
+
// reader, the Python SDK's warm-up, asks consent itself.)
|
|
231
|
+
executionSnapshot: config.introspect ? config.executionSnapshot : null,
|
|
169
232
|
mode: config.mode,
|
|
170
233
|
agentId: config.agentId,
|
|
171
234
|
});
|
|
172
|
-
if (config.executionSnapshot !== null && !
|
|
235
|
+
if (config.executionSnapshot !== null && !config.introspect) {
|
|
236
|
+
logger?.warn?.(
|
|
237
|
+
`[${PLUGIN_ID}] exact execution identity is disabled: introspection is ` +
|
|
238
|
+
`off (introspect / PERCEPTEYE_INTROSPECT), and identity is derived from ` +
|
|
239
|
+
`the description. Capture continues without an execution fingerprint.`,
|
|
240
|
+
);
|
|
241
|
+
} else if (config.executionSnapshot !== null && !tracker.enabled) {
|
|
173
242
|
logger?.warn?.(
|
|
174
243
|
`[${PLUGIN_ID}] exact execution identity is disabled: ` +
|
|
175
|
-
`${
|
|
244
|
+
`${tracker.refusal}. Capture continues without an affirmative ` +
|
|
176
245
|
`execution fingerprint.`,
|
|
177
246
|
);
|
|
178
247
|
}
|
|
248
|
+
// A DECLARED description is the customer's word, not an observation: it
|
|
249
|
+
// merges over what was observed and anchors no fingerprint, anywhere.
|
|
250
|
+
const executionIdentity = declaration === null
|
|
251
|
+
? tracker
|
|
252
|
+
: withholdFingerprints(tracker);
|
|
253
|
+
if (declaration !== null && tracker.enabled) {
|
|
254
|
+
// Said, because an operator who configured an execution snapshot and then
|
|
255
|
+
// added `describe` otherwise loses exact identity with no line anywhere.
|
|
256
|
+
logger?.warn?.(
|
|
257
|
+
`[${PLUGIN_ID}] exact execution identity is withheld: ` +
|
|
258
|
+
`${executionIdentity.refusal}. Remove describe to restore it.`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
179
261
|
// ── WHERE TOOL CALLS LAND, decided by mode ────────────────────────────
|
|
180
262
|
//
|
|
181
263
|
// In TRAINING mode a trajectory belongs to one ROLLOUT, and the rollout
|
|
@@ -244,6 +326,7 @@ export function register(api, pluginConfig = {}, env = process.env) {
|
|
|
244
326
|
)
|
|
245
327
|
: null,
|
|
246
328
|
executionIdentity,
|
|
329
|
+
declaration,
|
|
247
330
|
});
|
|
248
331
|
} catch (err) {
|
|
249
332
|
logger?.warn?.(
|
|
@@ -272,7 +355,7 @@ export function register(api, pluginConfig = {}, env = process.env) {
|
|
|
272
355
|
// inputs is a second answer that drifts the moment a cause is added.
|
|
273
356
|
const conversationAccess = conversationAccessGranted(api, PLUGIN_ID);
|
|
274
357
|
capture = registerTurnCapture(api, {
|
|
275
|
-
config, turns, pluginId: PLUGIN_ID, logger, env,
|
|
358
|
+
config, turns, pluginId: PLUGIN_ID, logger, env, declaration,
|
|
276
359
|
});
|
|
277
360
|
// THE LAST MILE. `capture` owns the one control-plane client, so this
|
|
278
361
|
// reuses it rather than building a second against the same key -- and
|
package/src/rollout.js
CHANGED
|
@@ -88,6 +88,8 @@
|
|
|
88
88
|
* at report time -- and when it is missing the operator gets one line naming
|
|
89
89
|
* the exact setting, instead of a queue that quietly stops moving.
|
|
90
90
|
*/
|
|
91
|
+
import { applyDeclaration } from "./declaration.js";
|
|
92
|
+
import { FRAMEWORK } from "./describe.js";
|
|
91
93
|
import { ConfigurationError, LeaseLost } from "./errors.js";
|
|
92
94
|
import { isLowerSha256 } from "./execution-identity.js";
|
|
93
95
|
import { conversationAccessGranted, lastAssistantText } from "./host.js";
|
|
@@ -670,6 +672,7 @@ export function createRolloutDriver({
|
|
|
670
672
|
*/
|
|
671
673
|
export function registerRolloutDriver(api, {
|
|
672
674
|
config, transport, pluginId, logger = null, executionIdentity = null,
|
|
675
|
+
declaration = null,
|
|
673
676
|
}) {
|
|
674
677
|
const verdict = driveVerdict(config, api, pluginId);
|
|
675
678
|
if (!verdict.canDrive) {
|
|
@@ -702,13 +705,27 @@ export function registerRolloutDriver(api, {
|
|
|
702
705
|
concurrency: 1,
|
|
703
706
|
input_shape: "text",
|
|
704
707
|
entrypoint: "openclaw-plugin",
|
|
705
|
-
// A startup observation is optional. When an adapter can provide one, the
|
|
706
|
-
// same immutable exact evidence drives both discovery and the execution
|
|
707
|
-
// digest registration sends before the first claim. Otherwise null says
|
|
708
|
-
// discovery ran but was unreadable; it never invents a worker identity.
|
|
709
|
-
discovered_agent: registrationDescription ?? null,
|
|
710
708
|
};
|
|
711
|
-
|
|
709
|
+
// CONSENT, asked as the Python SDK's serve() asks it. Refused, the key is
|
|
710
|
+
// OMITTED, which the control plane records as the customer's opt-out
|
|
711
|
+
// (`disabled`). Given: a startup observation is optional -- when an adapter
|
|
712
|
+
// can provide one, the same immutable exact evidence drives both discovery
|
|
713
|
+
// and the execution digest registration sends before the first claim -- and
|
|
714
|
+
// what the operator declared (`describe`) wins over it where given. With
|
|
715
|
+
// neither, null says discovery ran but was unreadable; it never invents a
|
|
716
|
+
// worker identity, and a declaration never anchors one (index.js withholds
|
|
717
|
+
// the fingerprint).
|
|
718
|
+
const consented = config.introspect !== false;
|
|
719
|
+
if (consented) {
|
|
720
|
+
registrationSpec.discovered_agent = applyDeclaration(declaration, {
|
|
721
|
+
reflected: registrationDescription ?? null,
|
|
722
|
+
agentName: config.agentId,
|
|
723
|
+
// With nothing observed, the host is still known: this is OpenClaw.
|
|
724
|
+
framework: FRAMEWORK,
|
|
725
|
+
}) ?? null;
|
|
726
|
+
}
|
|
727
|
+
// The digest is derived from the description, so the same refusal covers it.
|
|
728
|
+
if (consented && isLowerSha256(registrationFingerprint?.execution_sha256)) {
|
|
712
729
|
registrationSpec.agent_execution_sha256 =
|
|
713
730
|
registrationFingerprint.execution_sha256;
|
|
714
731
|
}
|