@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/mode.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH MODE THIS PROCESS IS IN, and what that permits.
|
|
3
|
+
*
|
|
4
|
+
* TWO GATES, AND THEY ARE NOT THE SAME GATE. Conflating them is how a package
|
|
5
|
+
* ends up either uploading somebody's end users' messages without consent, or
|
|
6
|
+
* refusing to do the training work it was installed for.
|
|
7
|
+
*
|
|
8
|
+
* MODE -- a CLIENT-side deployment switch, `PERCEPTEYE_AGENT_MODE`.
|
|
9
|
+
* `training` means this install does training work: it
|
|
10
|
+
* claims rollouts, hands them to the agent, and reports
|
|
11
|
+
* what happened. `production` means it does not.
|
|
12
|
+
*
|
|
13
|
+
* CAPTURE CONSENT -- a SERVER-side, per-agent decision, echoed to us at
|
|
14
|
+
* registration as `capture: {turns, reason}` and backed by
|
|
15
|
+
* `FlywheelAgent.production_capture_enabled`. It governs
|
|
16
|
+
* whether captured PRODUCTION turns may be uploaded.
|
|
17
|
+
*
|
|
18
|
+
* A customer in training mode has consented to nothing about their end users;
|
|
19
|
+
* a customer with capture consent has not thereby volunteered to run rollouts.
|
|
20
|
+
* So each question is asked separately and neither answer is derived from the
|
|
21
|
+
* other.
|
|
22
|
+
*
|
|
23
|
+
* WHAT PRODUCTION MODE PROMISES, precisely. This was once "OBSERVE-ONLY,
|
|
24
|
+
* absolutely", which was already a promise this package could not keep once it
|
|
25
|
+
* grew a rollout driver, and stopped being true a second time when production
|
|
26
|
+
* mode began applying the control plane's approved system prompt. An untrue
|
|
27
|
+
* promise about somebody's agent is worse than a narrower true one, so here is
|
|
28
|
+
* the narrow one, which holds:
|
|
29
|
+
*
|
|
30
|
+
* production mode -- CLAIMS NO WORK AND STARTS NO TURN. It does not claim a
|
|
31
|
+
* rollout, does not start or schedule a turn, does not
|
|
32
|
+
* queue an injection, and does not alter a tool result.
|
|
33
|
+
* The rollout hooks are NOT REGISTERED, so that much is
|
|
34
|
+
* the absence of a subscription rather than a branch
|
|
35
|
+
* inside a handler that a later edit could invert.
|
|
36
|
+
* It DOES capture the turns the agent serves, and it DOES
|
|
37
|
+
* apply what the control plane has approved -- replacing
|
|
38
|
+
* the agent's system prompt, and pointing it at the model
|
|
39
|
+
* that prompt was certified with. That is the last mile
|
|
40
|
+
* of the loop, off with PERCEPTEYE_APPLY_PROMPT=0 and
|
|
41
|
+
* PERCEPTEYE_APPLY_MODEL=0 respectively. See `policy.js`;
|
|
42
|
+
* those are deliberate changes to what the agent does and
|
|
43
|
+
* the only things in this mode that are not observation.
|
|
44
|
+
* Both are scoped to THIS agent's own served turns --
|
|
45
|
+
* never a subagent's, never a cron turn's, never another
|
|
46
|
+
* agent's in the same process (`scope.js`).
|
|
47
|
+
* training mode -- the plugin drives: it claims a rollout, starts a turn
|
|
48
|
+
* carrying the task, and contributes the trajectory. It
|
|
49
|
+
* applies NEITHER half: a rollout is evidence about the
|
|
50
|
+
* agent as configured, and changing its prompt or its
|
|
51
|
+
* model under one moves the behaviour with no weight
|
|
52
|
+
* change behind it.
|
|
53
|
+
*
|
|
54
|
+
* That distinction is structural on purpose. `registerRolloutDriver` is called
|
|
55
|
+
* from ONE place, under one predicate, and in production mode it is never
|
|
56
|
+
* reached -- and `registerServingPolicy` is called from the other branch of
|
|
57
|
+
* that same predicate, so neither can leak into the other's mode. As against a
|
|
58
|
+
* `if (mode === "training")` inside each handler, where a missed call site is a
|
|
59
|
+
* silent breach of the promise rather than a hook that is simply not there.
|
|
60
|
+
*
|
|
61
|
+
* The vocabulary is the PYTHON SDK's, verbatim (`agent_flywheel`'s
|
|
62
|
+
* `_MODE_ALIASES`). A customer switching harnesses must not have to learn a
|
|
63
|
+
* second spelling of the same switch -- they deploy one package or the other,
|
|
64
|
+
* never both, and `PERCEPTEYE_AGENT_MODE=prod` has to mean the same thing in
|
|
65
|
+
* whichever one they chose.
|
|
66
|
+
*/
|
|
67
|
+
import { ConfigurationError } from "./errors.js";
|
|
68
|
+
|
|
69
|
+
export const ENV_MODE = "PERCEPTEYE_AGENT_MODE";
|
|
70
|
+
|
|
71
|
+
export const TRAINING = "training";
|
|
72
|
+
export const PRODUCTION = "production";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Every accepted spelling, mapped to the mode it means.
|
|
76
|
+
*
|
|
77
|
+
* A CLOSED SET, and that is the point. `rollout` is here because it is the
|
|
78
|
+
* legacy value and its meaning never changed; `serving` because that is what
|
|
79
|
+
* an ops team calls the same deployment. Anything NOT in this table is a
|
|
80
|
+
* refusal, never a default -- see `ConfigurationError`.
|
|
81
|
+
*/
|
|
82
|
+
export const MODE_ALIASES = Object.freeze({
|
|
83
|
+
training: TRAINING,
|
|
84
|
+
train: TRAINING,
|
|
85
|
+
rollout: TRAINING, // the legacy value, unchanged in meaning
|
|
86
|
+
rollouts: TRAINING,
|
|
87
|
+
"fine-tuning": TRAINING,
|
|
88
|
+
finetuning: TRAINING,
|
|
89
|
+
improving: TRAINING,
|
|
90
|
+
production: PRODUCTION,
|
|
91
|
+
prod: PRODUCTION,
|
|
92
|
+
serving: PRODUCTION,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function modeFrom(raw, source) {
|
|
96
|
+
const key = String(raw).trim().toLowerCase();
|
|
97
|
+
if (!Object.hasOwn(MODE_ALIASES, key)) {
|
|
98
|
+
throw new ConfigurationError(
|
|
99
|
+
`${source}${JSON.stringify(String(raw))} is not a mode. Use 'training' ` +
|
|
100
|
+
`(this process does training work: it claims rollouts and runs them) ` +
|
|
101
|
+
`or 'production' (it does not). Recognised spellings: ` +
|
|
102
|
+
`${Object.keys(MODE_ALIASES).sort().join(", ")}.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return MODE_ALIASES[key];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The mode, and which of `config`/`environment`/`default` decided it.
|
|
110
|
+
*
|
|
111
|
+
* An EMPTY explicit value falls through to the environment rather than being
|
|
112
|
+
* honoured. `{mode: cfg.mode ?? ""}` is the ordinary way a config layer says
|
|
113
|
+
* "not set", and treating that as a value pinned the process to `training`
|
|
114
|
+
* without ever reading the environment -- so an operator who had flipped the
|
|
115
|
+
* switch to stop contributing kept contributing, on their own credentials,
|
|
116
|
+
* silently. Same reasoning as the Python SDK's `_resolve_mode_source`.
|
|
117
|
+
*/
|
|
118
|
+
export function resolveModeSource(explicit = null, env = process.env) {
|
|
119
|
+
if (explicit !== null && explicit !== undefined && String(explicit).trim() !== "") {
|
|
120
|
+
return { mode: modeFrom(explicit, "mode="), source: "config" };
|
|
121
|
+
}
|
|
122
|
+
const raw = env?.[ENV_MODE];
|
|
123
|
+
if (raw !== null && raw !== undefined && String(raw).trim() !== "") {
|
|
124
|
+
return { mode: modeFrom(raw, `${ENV_MODE}=`), source: "environment" };
|
|
125
|
+
}
|
|
126
|
+
return { mode: TRAINING, source: "default" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `"training"` or `"production"`. Throws `ConfigurationError` on a typo.
|
|
131
|
+
*
|
|
132
|
+
* Precedence is the plugin config, then `PERCEPTEYE_AGENT_MODE`, then
|
|
133
|
+
* `training` -- the same order and the same default as Python's
|
|
134
|
+
* `resolve_mode`, so one env var configures either package identically.
|
|
135
|
+
*/
|
|
136
|
+
export function resolveMode(explicit = null, env = process.env) {
|
|
137
|
+
return resolveModeSource(explicit, env).mode;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// THE SECOND GATE LIVES IN `capture.js`.
|
|
141
|
+
//
|
|
142
|
+
// Uploading a captured PRODUCTION turn needs a second yes, separate from mode:
|
|
143
|
+
// Mission Control's per-agent `production_capture_enabled`, which is opt-in
|
|
144
|
+
// (NULL is not consent) because a production turn carries the customer's END
|
|
145
|
+
// USERS' messages. It arrives as `capture: {turns, reason}` on the
|
|
146
|
+
// registration response, precisely so a client never learns its permission BY
|
|
147
|
+
// uploading, and `capture.js:captureVerdict` is the only thing that reads it.
|
|
148
|
+
//
|
|
149
|
+
// It was written HERE first and removed before commit, because nothing then
|
|
150
|
+
// uploaded turns and a predicate with no consumer is the shape this codebase
|
|
151
|
+
// keeps finding. It came back with its consumer, in the module that owns the
|
|
152
|
+
// question -- not here, where it would have been a second place to look.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Independent model-call accounting for one bounded rollout lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* The control plane observes requests that reach its gateway. This counter is
|
|
5
|
+
* deliberately the other witness: a host adapter feeds provider-call start
|
|
6
|
+
* and terminal events, identified by the host's own run and call ids. It
|
|
7
|
+
* never reads a gateway count and never derives a count from transcript
|
|
8
|
+
* messages. Missing, duplicate, cross-session, or unterminated evidence
|
|
9
|
+
* yields no count rather than a guess.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const MAX_PENDING_RUNS = 1024;
|
|
13
|
+
|
|
14
|
+
const nonempty = (value) => (
|
|
15
|
+
typeof value === "string" && value.trim() ? value.trim() : null
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const runIdOf = (event, ctx) => nonempty(event?.runId) ?? nonempty(ctx?.runId);
|
|
19
|
+
const callIdOf = (event) => nonempty(event?.callId);
|
|
20
|
+
const sessionKeyOf = (event, ctx) => (
|
|
21
|
+
nonempty(event?.sessionKey) ?? nonempty(event?.sessionId)
|
|
22
|
+
?? nonempty(ctx?.sessionKey) ?? nonempty(ctx?.sessionId)
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
function newRun(sessionKey) {
|
|
26
|
+
return {
|
|
27
|
+
sessionKey,
|
|
28
|
+
started: new Set(),
|
|
29
|
+
ended: new Map(),
|
|
30
|
+
invalid: null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create a framework-neutral counter. An adapter owns the event mapping; core
|
|
36
|
+
* owns lifecycle binding and refuses evidence that cannot name one exact run.
|
|
37
|
+
*/
|
|
38
|
+
export function createModelCallCounter() {
|
|
39
|
+
/** session key -> active rollout lifecycle */
|
|
40
|
+
const active = new Map();
|
|
41
|
+
/** host run id -> independently observed provider calls */
|
|
42
|
+
const runs = new Map();
|
|
43
|
+
|
|
44
|
+
function cleanupSession(sessionKey) {
|
|
45
|
+
for (const [runId, record] of runs) {
|
|
46
|
+
if (record.sessionKey === sessionKey) runs.delete(runId);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function begin(sessionKey) {
|
|
51
|
+
const key = nonempty(sessionKey);
|
|
52
|
+
if (key === null || active.has(key)) return false;
|
|
53
|
+
active.set(key, { uncertain: null });
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function abandon(sessionKey) {
|
|
58
|
+
const key = nonempty(sessionKey);
|
|
59
|
+
if (key === null) return false;
|
|
60
|
+
const removed = active.delete(key);
|
|
61
|
+
cleanupSession(key);
|
|
62
|
+
return removed;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function markEveryActive(reason) {
|
|
66
|
+
for (const state of active.values()) state.uncertain ??= reason;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function recordFor(event, ctx, phase) {
|
|
70
|
+
if (active.size === 0) return null;
|
|
71
|
+
const sessionKey = sessionKeyOf(event, ctx);
|
|
72
|
+
if (sessionKey === null) {
|
|
73
|
+
// With concurrent rollouts there is no principled target. Poison every
|
|
74
|
+
// candidate rather than attaching an out-of-band call to whichever run
|
|
75
|
+
// happens to finish next.
|
|
76
|
+
markEveryActive(`${phase} carried no session identity`);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const lifecycle = active.get(sessionKey);
|
|
80
|
+
// Provider events for ordinary, non-rollout sessions are out of scope and
|
|
81
|
+
// must not contaminate a concurrently claimed rollout.
|
|
82
|
+
if (lifecycle === undefined) return null;
|
|
83
|
+
const runId = runIdOf(event, ctx);
|
|
84
|
+
if (runId === null) {
|
|
85
|
+
lifecycle.uncertain ??= `${phase} carried no run id`;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
let record = runs.get(runId);
|
|
89
|
+
if (record === undefined) {
|
|
90
|
+
record = newRun(sessionKey);
|
|
91
|
+
runs.set(runId, record);
|
|
92
|
+
while (runs.size > MAX_PENDING_RUNS) {
|
|
93
|
+
const evictedRunId = runs.keys().next().value;
|
|
94
|
+
const evicted = runs.get(evictedRunId);
|
|
95
|
+
runs.delete(evictedRunId);
|
|
96
|
+
const owner = active.get(evicted?.sessionKey);
|
|
97
|
+
if (owner !== undefined) {
|
|
98
|
+
owner.uncertain ??= "model-call evidence exceeded the pending-run limit";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} else if (record.sessionKey !== sessionKey) {
|
|
102
|
+
record.invalid ??= "one run carried conflicting session identities";
|
|
103
|
+
lifecycle.uncertain ??= record.invalid;
|
|
104
|
+
const owner = active.get(record.sessionKey);
|
|
105
|
+
if (owner !== undefined) {
|
|
106
|
+
owner.uncertain ??= record.invalid;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return record;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function observeStarted(event, ctx = {}) {
|
|
114
|
+
const record = recordFor(event, ctx, "model-call start");
|
|
115
|
+
if (record === null) return false;
|
|
116
|
+
const callId = callIdOf(event);
|
|
117
|
+
if (callId === null) {
|
|
118
|
+
record.invalid ??= "a model-call start carried no call id";
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if (record.started.has(callId)) {
|
|
122
|
+
record.invalid ??= `model-call start ${JSON.stringify(callId)} was duplicated`;
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
record.started.add(callId);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function observeEnded(event, ctx = {}) {
|
|
130
|
+
const record = recordFor(event, ctx, "model-call end");
|
|
131
|
+
if (record === null) return false;
|
|
132
|
+
const callId = callIdOf(event);
|
|
133
|
+
if (callId === null) {
|
|
134
|
+
record.invalid ??= "a model-call end carried no call id";
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
if (!record.started.has(callId)) {
|
|
138
|
+
record.invalid ??= `model-call end ${JSON.stringify(callId)} had no observed start`;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if (record.ended.has(callId)) {
|
|
142
|
+
record.invalid ??= `model-call end ${JSON.stringify(callId)} was duplicated`;
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
if (event?.outcome !== "completed" && event?.outcome !== "error") {
|
|
146
|
+
record.invalid ??= `model-call end ${JSON.stringify(callId)} had no terminal outcome`;
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
record.ended.set(callId, event.outcome);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Consume the measurement exactly once when the matching agent run ends. */
|
|
154
|
+
function finish(sessionKey, event, ctx = {}) {
|
|
155
|
+
const key = nonempty(sessionKey);
|
|
156
|
+
if (key === null) {
|
|
157
|
+
return { count: null, reason: "the rollout had no session identity" };
|
|
158
|
+
}
|
|
159
|
+
const lifecycle = active.get(key);
|
|
160
|
+
active.delete(key);
|
|
161
|
+
const runId = runIdOf(event, ctx);
|
|
162
|
+
const record = runId === null ? undefined : runs.get(runId);
|
|
163
|
+
// Consume every observation owned by this lifecycle. A host may emit
|
|
164
|
+
// retries under several run ids; retaining the non-terminal records would
|
|
165
|
+
// let a later lifecycle inherit stale evidence if an id is reused.
|
|
166
|
+
cleanupSession(key);
|
|
167
|
+
if (lifecycle === undefined) {
|
|
168
|
+
return { count: null, reason: "no model-call observation lifecycle was active" };
|
|
169
|
+
}
|
|
170
|
+
if (lifecycle.uncertain !== null) {
|
|
171
|
+
return { count: null, reason: lifecycle.uncertain };
|
|
172
|
+
}
|
|
173
|
+
if (runId === null) {
|
|
174
|
+
return { count: null, reason: "agent_end carried no run id" };
|
|
175
|
+
}
|
|
176
|
+
if (record === undefined || record.started.size === 0) {
|
|
177
|
+
return {
|
|
178
|
+
count: null,
|
|
179
|
+
reason: "no provider-call start was observed for this run",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (record.invalid !== null) return { count: null, reason: record.invalid };
|
|
183
|
+
if (record.sessionKey !== key) {
|
|
184
|
+
return {
|
|
185
|
+
count: null,
|
|
186
|
+
reason: "model-call evidence belonged to a different session",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (record.ended.size !== record.started.size) {
|
|
190
|
+
return {
|
|
191
|
+
count: null,
|
|
192
|
+
reason: "one or more model calls had no observed terminal event",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
for (const outcome of record.ended.values()) {
|
|
196
|
+
if (outcome !== "completed") {
|
|
197
|
+
return {
|
|
198
|
+
count: null,
|
|
199
|
+
reason: "one or more model calls ended in error",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { count: record.started.size, reason: null };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
begin,
|
|
208
|
+
abandon,
|
|
209
|
+
observeStarted,
|
|
210
|
+
observeEnded,
|
|
211
|
+
finish,
|
|
212
|
+
get activeCount() { return active.size; },
|
|
213
|
+
};
|
|
214
|
+
}
|