@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/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
+ }