@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/scope.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * WHOSE RUN IS THIS, and does the control plane's answer apply to it?
3
+ *
4
+ * ONE PREDICATE, asked by BOTH halves of the last mile. The prompt half
5
+ * (`before_prompt_build`) and the model half (`before_model_resolve`) are two
6
+ * hooks with one question behind them -- "is this the flywheel agent's own
7
+ * turn?" -- and the moment each asked it for itself they would drift. So it is
8
+ * asked here, once, and `policy.js` consults nothing else.
9
+ *
10
+ * ── THE DEFECT THIS EXISTS TO CLOSE ───────────────────────────────────────
11
+ *
12
+ * The prompt handler took NO ARGUMENTS. It returned the approved prompt for
13
+ * every `before_prompt_build` in the process, and the host runs that hook for
14
+ * every agent, every subagent and every cron turn a gateway serves. An
15
+ * operator approving a bundle for ONE agent got a process-wide prompt swap:
16
+ * a subagent whose whole job is summarising a file got a support agent's
17
+ * instructions, and so did a scheduled maintenance turn.
18
+ *
19
+ * The host does supply the identity -- it was simply not read.
20
+ * `buildAgentHookContext` (lifecycle-hook-helpers-BwL6869q.js:9-34) puts
21
+ * `agentId` and `sessionKey` on the context both hooks receive, and both call
22
+ * sites pass it: `before_prompt_build` at
23
+ * agent-harness-runtime-827dyFNd.js:139,151 and
24
+ * attempt.prompt-helpers-kaS3UZwL.js:131-135, `before_model_resolve` at
25
+ * embedded-agent-DGUuxGR2.js:2292-2335. Verified against the INSTALLED host,
26
+ * OpenClaw 2026.7.1-2.
27
+ *
28
+ * ── WHAT A SESSION KEY SAYS ───────────────────────────────────────────────
29
+ *
30
+ * `agent:<agentId>:<rest>`, and `rest` carries the KIND. The host's own
31
+ * predicates are `isSubagentSessionKey` and `isCronSessionKey`
32
+ * (session-key-utils-A-JGvyXu.js:168-183); the two below are the same
33
+ * questions asked the same way, because a subagent and a cron turn are
34
+ * ORDINARY runs of the same agent and the session key is the only thing that
35
+ * tells them apart.
36
+ *
37
+ * They are re-implemented rather than imported: `openclaw/dist/*` is the
38
+ * host's private build output, its file names carry content hashes that move
39
+ * every release, and this package declares no dependency on the host at all.
40
+ * A copy that can go stale is the lesser evil against an import that breaks on
41
+ * the next patch release -- and both halves fail CLOSED if it ever does drift,
42
+ * because an unrecognised key shape is not a match.
43
+ *
44
+ * ── AND WHEN WE CANNOT TELL ───────────────────────────────────────────────
45
+ *
46
+ * Nothing is applied. That is the opposite of this file's neighbours -- the
47
+ * control-plane reads fail OPEN, because keeping the prompt you have is the
48
+ * safe answer there. Here the safe answer is the other one: applying another
49
+ * agent's approved prompt is a change nobody asked for, and declining to apply
50
+ * our own costs the improvement and nothing else.
51
+ */
52
+
53
+ /** The host's default agent id (`DEFAULT_AGENT_ID`, account-selection-CccGNkkz.js:5). */
54
+ export const DEFAULT_HOST_AGENT_ID = "main";
55
+
56
+ /**
57
+ * The host's own agent-id normalisation
58
+ * (`normalizeAgentId`, account-selection-CccGNkkz.js:5-8).
59
+ *
60
+ * Returns `null` rather than the default for an empty value, deliberately: the
61
+ * host folds "" to `main`, and folding it here would turn "the host named no
62
+ * agent" into "the host named the default agent" -- which is a claim, not an
63
+ * observation, and it is the claim that decides whether a prompt is swapped.
64
+ */
65
+ export function normalizeAgentId(value) {
66
+ const id = String(value ?? "").trim().toLowerCase()
67
+ .replace(/[^a-z0-9_-]+/g, "-").replace(/^-+/, "").replace(/-+$/, "");
68
+ return id || null;
69
+ }
70
+
71
+ /**
72
+ * `{agentId, rest}` for an agent-scoped session key, or null.
73
+ *
74
+ * Mirrors `parseAgentSessionKey` (session-key-utils-A-JGvyXu.js:154-167). The
75
+ * host preserves the case of a handful of opaque peer ids while lowercasing
76
+ * the rest; lowercasing the whole string is equivalent for every question
77
+ * asked here, because the segments read are the leading `agent:` wrapper and
78
+ * the `cron:` / `subagent:` marker that follows it, never a peer id.
79
+ */
80
+ export function parseAgentSessionKey(sessionKey) {
81
+ const raw = String(sessionKey ?? "").trim().toLowerCase();
82
+ if (!raw) return null;
83
+ const parts = raw.split(":");
84
+ if (parts.length < 3 || parts[0] !== "agent" || !parts[1] || !parts[2]) {
85
+ return null;
86
+ }
87
+ const rest = parts.slice(2).join(":");
88
+ return rest ? { agentId: parts[1], rest } : null;
89
+ }
90
+
91
+ /** A subagent's session. `isSubagentSessionKey`, :178-183. */
92
+ export function isSubagentSessionKey(sessionKey) {
93
+ const raw = String(sessionKey ?? "").trim().toLowerCase();
94
+ if (!raw) return false;
95
+ if (raw.startsWith("subagent:")) return true;
96
+ return parseAgentSessionKey(raw)?.rest.startsWith("subagent:") === true;
97
+ }
98
+
99
+ /** A cron turn's session. `isCronSessionKey`, :173-177. */
100
+ export function isCronSessionKey(sessionKey) {
101
+ const raw = String(sessionKey ?? "").trim().toLowerCase();
102
+ if (!raw) return false;
103
+ return parseAgentSessionKey(raw)?.rest.startsWith("cron:") === true;
104
+ }
105
+
106
+ /**
107
+ * The agent ids this host is configured to run.
108
+ *
109
+ * `cfg.agents.list[].id`, falling back to the single default agent -- the same
110
+ * shape and the same fallback as `listAgentIds`
111
+ * (agent-scope-config-BxAUeF6t.js:35-53). `api.config` is the host's own
112
+ * config object, the one its registry consults; `host.js` already reads it for
113
+ * the conversation-access opt-in.
114
+ */
115
+ export function hostAgentIds(hostConfig) {
116
+ const list = hostConfig?.agents?.list;
117
+ if (!Array.isArray(list)) return [DEFAULT_HOST_AGENT_ID];
118
+ const ids = [];
119
+ for (const entry of list) {
120
+ if (!entry || typeof entry !== "object") continue;
121
+ const id = normalizeAgentId(entry.id);
122
+ if (id && !ids.includes(id)) ids.push(id);
123
+ }
124
+ return ids.length ? ids : [DEFAULT_HOST_AGENT_ID];
125
+ }
126
+
127
+ /**
128
+ * WHICH HOST AGENT this install's approved bundle is for.
129
+ *
130
+ * The two names are in different namespaces and this is where they are
131
+ * joined. `agentId` is what the plugin registers with the control plane and
132
+ * what `/prompt/current` answers about; `ctx.agentId` is the OpenClaw agent id
133
+ * the host runs turns under, whose default is `main`. Comparing them raw would
134
+ * refuse every default install (`openclaw` != `main`) and call that safety.
135
+ *
136
+ * So, in order:
137
+ *
138
+ * 1. the configured name IS one of this host's agents -- use it. An operator
139
+ * running several agents names the one this install serves and gets
140
+ * exactly that agent.
141
+ * 2. this host runs exactly ONE agent -- use it. There is nothing else this
142
+ * bundle could be for, and this is the ordinary install.
143
+ * 3. several agents, none of them named -- REFUSE, with the remedy. This is
144
+ * the only genuinely ambiguous case and it is the one where guessing
145
+ * swaps a bystander's prompt.
146
+ *
147
+ * @returns {{id: string|null, sole: boolean, reason: string}}
148
+ */
149
+ export function resolveServedAgent({ agentId, hostConfig } = {}) {
150
+ const ids = hostAgentIds(hostConfig);
151
+ const sole = ids.length === 1;
152
+ const wanted = normalizeAgentId(agentId);
153
+ if (wanted && ids.includes(wanted)) return { id: wanted, sole, reason: "" };
154
+ if (sole) return { id: ids[0], sole: true, reason: "" };
155
+ return {
156
+ id: null,
157
+ sole: false,
158
+ reason:
159
+ `this host runs ${ids.length} agents (${ids.join(", ")}) and none of ` +
160
+ `them is named ${JSON.stringify(wanted ?? "")}, so there is no way to ` +
161
+ "tell which one the control plane's answer is about. Set this " +
162
+ "plugin's `agentId` to the OpenClaw agent id it serves",
163
+ };
164
+ }
165
+
166
+ /**
167
+ * DOES THE APPROVED ANSWER APPLY TO THIS RUN?
168
+ *
169
+ * @param {object|null} ctx the host's hook context
170
+ * @param {{id: string|null, sole: boolean, reason: string}} served
171
+ * @returns {{apply: boolean, reason: string}} `reason` is "" only when applying
172
+ */
173
+ export function scopeDecision(ctx, served) {
174
+ if (!served || !served.id) {
175
+ return { apply: false, reason: served?.reason || "no agent is resolved" };
176
+ }
177
+ if (!ctx || typeof ctx !== "object") {
178
+ return {
179
+ apply: false,
180
+ reason: "the host passed no hook context, so this run cannot be told " +
181
+ "apart from another agent's",
182
+ };
183
+ }
184
+ const sessionKey = String(ctx.sessionKey ?? "").trim();
185
+ const hostAgent = normalizeAgentId(ctx.agentId)
186
+ ?? parseAgentSessionKey(sessionKey)?.agentId
187
+ ?? null;
188
+
189
+ // NEITHER IDENTIFIER. Not a shape this host produces for an agent turn --
190
+ // `buildAgentHookContext` carries both -- so it is a host we do not know,
191
+ // and the answer to "is this our agent" is that we cannot tell.
192
+ if (!sessionKey && !hostAgent) {
193
+ return {
194
+ apply: false,
195
+ reason: "the host named neither an agent nor a session for this run, " +
196
+ "so it cannot be told apart from another agent's",
197
+ };
198
+ }
199
+
200
+ // A SUBAGENT IS NOT THE AGENT. It runs the same host agent id under a
201
+ // different job -- summarise this, search that -- and the approved bundle
202
+ // was certified against the turns the agent serves its users, which is the
203
+ // population the control plane graded and trained on.
204
+ if (isSubagentSessionKey(sessionKey)) {
205
+ return {
206
+ apply: false,
207
+ reason: "this is a subagent session; the approved bundle was certified " +
208
+ "for the agent's own turns, not for the jobs it delegates",
209
+ };
210
+ }
211
+
212
+ // A CRON TURN IS NOT A SERVED TURN. Nobody is waiting on it and nothing
213
+ // about it was measured.
214
+ if (isCronSessionKey(sessionKey)) {
215
+ return {
216
+ apply: false,
217
+ reason: "this is a scheduled (cron) session, not a turn this agent is " +
218
+ "serving a user",
219
+ };
220
+ }
221
+
222
+ if (hostAgent) {
223
+ return hostAgent === served.id
224
+ ? { apply: true, reason: "" }
225
+ : {
226
+ apply: false,
227
+ reason: `this run belongs to host agent ${JSON.stringify(hostAgent)}, ` +
228
+ `and the control plane's answer is for ${JSON.stringify(served.id)}`,
229
+ };
230
+ }
231
+
232
+ // A session key we could parse no agent out of. Safe on a single-agent
233
+ // host, because there is no other agent it could belong to; on a host
234
+ // running several it is exactly the ambiguity that has to fail closed.
235
+ return served.sole
236
+ ? { apply: true, reason: "" }
237
+ : {
238
+ apply: false,
239
+ reason: "the host named no agent for this run and this host runs " +
240
+ "several, so it cannot be attributed to one",
241
+ };
242
+ }
package/src/session.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Where this package's files live on the machine.
3
+ *
4
+ * WHAT USED TO BE HERE, and why it is gone. This module carried a
5
+ * `SessionStore` -- per-session in-memory state plus a crash-safe on-disk
6
+ * "spool" for a session-end report that the network might truncate. It was
7
+ * complete and it was tested, and it had NO producer and NO consumer: nothing
8
+ * in `src/` ever imported it, only its own test did. It was written for a
9
+ * session-report design that production turn capture then superseded.
10
+ *
11
+ * `capture.js` needs none of it, and that is the point rather than an accident:
12
+ * a captured turn's durable state IS its directory, written as the hooks fire.
13
+ * There is no pending map to leak inside a long-lived gateway, nothing to
14
+ * reconstruct after a restart, and no shutdown window in which a report exists
15
+ * only in flight. The spool solved a problem that design does not have.
16
+ *
17
+ * So it was deleted rather than left in place with a comment. Dead code that
18
+ * looks finished is worse than absent code: the next person wires it up.
19
+ *
20
+ * What remains is the two things that are actually used.
21
+ */
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ /** The root for everything this package writes. */
26
+ export function percepteyeHome(env = process.env) {
27
+ return env.PERCEPTEYE_HOME || join(homedir(), ".percepteye");
28
+ }
29
+
30
+ /**
31
+ * A host-supplied identifier, made safe to use as ONE path segment.
32
+ *
33
+ * Allows no dots, deliberately: a name of `..` that survived a filter would
34
+ * make `join(root, name)` the PARENT directory. `turns.js:safeTurnId` allows
35
+ * dots -- so that a turn id like `run.1` survives intact -- and rejects the
36
+ * two path-special dot segments explicitly for exactly this reason.
37
+ */
38
+ export function safeSegment(value) {
39
+ const s = String(value ?? "");
40
+ const cleaned = [...s].filter((c) => /[A-Za-z0-9_-]/.test(c)).join("");
41
+ return cleaned.slice(0, 120) || "unknown";
42
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The trajectory writer: append-only JSONL, one line per executed tool call.
3
+ *
4
+ * This file is the wire format, not an implementation detail. The Python SDK
5
+ * reads exactly `$PERCEPTEYE_TRAJECTORY_DIR/tool_calls.jsonl`, so this module
6
+ * and `agent_flywheel.outcomes` are two ends of one ABI. Anything
7
+ * that can append a line can feed the flywheel -- that is why the format is a
8
+ * file and not a function call.
9
+ *
10
+ * Two rules the Python end depends on:
11
+ * - append-only and flushed per call, so a killed run keeps every call up to
12
+ * the kill rather than losing all of them;
13
+ * - one JSON object per line, because the reader recovers per line. A
14
+ * truncated final write costs that record and nothing else.
15
+ *
16
+ * Zero dependencies, `node:fs` only. This runs inside someone else's agent.
17
+ */
18
+ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+
21
+ export const TRAJECTORY_DIR_ENV = "PERCEPTEYE_TRAJECTORY_DIR";
22
+ export const TOOL_CALLS_FILENAME = "tool_calls.jsonl";
23
+
24
+ /** Keys the contract accepts, in the order `to_wire()` emits them. */
25
+ const OPTIONAL_KEYS = [
26
+ "output", "error", "error_class", "latency_ms", "agent_name", "tool_call_id",
27
+ ];
28
+
29
+ /**
30
+ * One record, shaped to match `ToolCallOutcome.to_wire()` exactly.
31
+ *
32
+ * `status_code` is always present and always null from here: OpenClaw's tool
33
+ * stream carries no HTTP status, and inventing one is precisely the failure
34
+ * the tri-state exists to prevent. A null status with a real outcome is the
35
+ * honest shape; the contract requires the key, not a number.
36
+ */
37
+ export function toWire({ name, args, outcome, status_code = null, entity_ids,
38
+ ...rest }) {
39
+ const d = {
40
+ name,
41
+ arguments: args && typeof args === "object" ? args : {},
42
+ outcome,
43
+ status_code: status_code ?? null,
44
+ };
45
+ for (const key of OPTIONAL_KEYS) {
46
+ const v = rest[key];
47
+ if (v !== undefined && v !== null) d[key] = v;
48
+ }
49
+ // Emitted only when non-empty, and emitted for EVERY outcome including
50
+ // `unknown`. An identity claims nothing about whether the call worked -- it
51
+ // names an entity so something else can go and observe one. Withholding it
52
+ // on `unknown` would suppress the only evidence able to settle that call.
53
+ if (entity_ids && Object.keys(entity_ids).length > 0) {
54
+ d.entity_ids = entity_ids;
55
+ }
56
+ return d;
57
+ }
58
+
59
+ /**
60
+ * A writer bound to a directory, or an inert one when no directory is set.
61
+ *
62
+ * The env var is read per call rather than cached at construction: a host that
63
+ * sets it late still gets capture, and a host that never sets it never gets a
64
+ * stray file. Absence is the normal production case, not an error -- the same
65
+ * plugin stays installed whether or not a rollout is running.
66
+ */
67
+ export function createWriter({ dir = null, resolveDir = null,
68
+ onError = null } = {}) {
69
+ let warned = false;
70
+
71
+ // `resolveDir` lets a caller route each record to its OWN directory -- which
72
+ // is what production turn capture needs, because there a trajectory belongs
73
+ // to one turn rather than to one rollout. It receives the record's context
74
+ // (`{runId}`) and may return null to drop the record.
75
+ //
76
+ // An OPTION rather than a second writer module: the append semantics below
77
+ // are the wire ABI shared with the Python SDK, and a second implementation
78
+ // of them is a second thing to keep true.
79
+ const resolve = resolveDir
80
+ ?? (() => dir ?? process.env[TRAJECTORY_DIR_ENV] ?? null);
81
+
82
+ return {
83
+ get active() {
84
+ return Boolean(resolveDir ? true : resolve());
85
+ },
86
+ /** @returns {boolean} whether the record was written. */
87
+ write(record, ctx = undefined) {
88
+ const base = resolve(ctx);
89
+ if (!base) return false;
90
+ const path = join(base, TOOL_CALLS_FILENAME);
91
+ try {
92
+ mkdirSync(dirname(path), { recursive: true });
93
+ // 'a' is O_APPEND: concurrent writers interleave whole lines rather
94
+ // than corrupting each other, which matters because a Gateway runs
95
+ // tools concurrently.
96
+ appendFileSync(path, JSON.stringify(record) + "\n", "utf8");
97
+ return true;
98
+ } catch (err) {
99
+ // Never fail an agent over telemetry. Report once, then stay quiet:
100
+ // a per-call warning on a full disk would bury the agent's own output.
101
+ if (!warned) {
102
+ warned = true;
103
+ if (onError) onError(err);
104
+ }
105
+ return false;
106
+ }
107
+ },
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Read a trajectory back, preserving the two distinctions the report depends on.
113
+ *
114
+ * `null` vs `[]` is load-bearing at BOTH ends: null means "there was no
115
+ * trajectory to read", `[]` means "I read one and it recorded zero calls". The
116
+ * writer's absence and an empty run are different claims.
117
+ *
118
+ * Unparseable lines are COUNTED, never thrown on and never skipped silently.
119
+ * The tally becomes `tool_calls_omitted_count`, whose non-zero value forbids the
120
+ * training grade -- so losing it would let a partly-unreadable trajectory be
121
+ * graded as a complete one.
122
+ */
123
+ export function readTrajectory(dir) {
124
+ if (!dir) return { calls: null, malformed: 0 };
125
+ const path = join(dir, TOOL_CALLS_FILENAME);
126
+ let text;
127
+ try {
128
+ text = readFileSync(path, "utf8");
129
+ } catch {
130
+ return { calls: null, malformed: 0 };
131
+ }
132
+ const calls = [];
133
+ let malformed = 0;
134
+ for (const line of text.split("\n")) {
135
+ if (!line.trim()) continue;
136
+ try {
137
+ const row = JSON.parse(line);
138
+ // The reader the Python end uses builds each row with REQUIRED
139
+ // subscripts and counts anything else as malformed. Mirror that: a row
140
+ // missing either field is not a tool call, it is corruption.
141
+ if (row && typeof row === "object" && row.name && row.outcome) calls.push(row);
142
+ else malformed += 1;
143
+ } catch {
144
+ malformed += 1;
145
+ }
146
+ }
147
+ return { calls, malformed };
148
+ }