@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/turns.js
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A captured production turn, on disk, in the format the Python SDK already
|
|
3
|
+
* writes and reads.
|
|
4
|
+
*
|
|
5
|
+
* ONE FORMAT, TWO IMPLEMENTATIONS. A customer deploys this package or the
|
|
6
|
+
* Python one, never both -- so the two must agree, and the thing that makes
|
|
7
|
+
* them agree cannot be a comment saying so. It is this: a turn is a DIRECTORY
|
|
8
|
+
* named for the turn id, with the shared core files below and two optional
|
|
9
|
+
* execution-identity files whose names and shapes also match Python:
|
|
10
|
+
*
|
|
11
|
+
* tool_calls.jsonl one JSON object per executed tool call (already ours:
|
|
12
|
+
* `trajectory.js` has written it since day one, and it is
|
|
13
|
+
* why nothing here reimplements it)
|
|
14
|
+
* turns.jsonl {kind:"task"|"answer", text, turn_id,
|
|
15
|
+
* conversation_id, task_id}
|
|
16
|
+
* conversation_id the conversation this turn belongs to, as bare text
|
|
17
|
+
* task_id caller-authored case/work-item correlation, as text
|
|
18
|
+
* task_id.conflict monotonic abstention when two task bindings disagree
|
|
19
|
+
* agent_fingerprint.json an exact, SDK-reconciled execution fingerprint
|
|
20
|
+
* agent_fingerprint.conflict permanent abstention if writers disagree
|
|
21
|
+
*
|
|
22
|
+
* The Python end is `agent_flywheel.outcomes` (`record_turn_text`,
|
|
23
|
+
* `read_turn_text`, `read_conversation_id`) and `production.Attachment._turn_wire`.
|
|
24
|
+
* The rules below are not restatements of that code; they are the same rules,
|
|
25
|
+
* and where one is subtle the reason is given so the two do not drift apart by
|
|
26
|
+
* someone "simplifying" one side.
|
|
27
|
+
*
|
|
28
|
+
* WHAT MAKES A TURN HERE. OpenClaw gives every agent run a `runId`, and it is
|
|
29
|
+
* on all three hooks this lane uses -- `message_received` (the end user's
|
|
30
|
+
* message), `after_tool_call` (event AND context), and `agent_end` (the
|
|
31
|
+
* answer). So the turn boundary is DECLARED by the host rather than inferred
|
|
32
|
+
* by us, which is the same standard the Python side holds: guessing where one
|
|
33
|
+
* turn ends and the next begins is how one user's tool calls end up in
|
|
34
|
+
* another's record.
|
|
35
|
+
*/
|
|
36
|
+
import {
|
|
37
|
+
appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync,
|
|
38
|
+
writeFileSync,
|
|
39
|
+
} from "node:fs";
|
|
40
|
+
import { basename, join } from "node:path";
|
|
41
|
+
|
|
42
|
+
import { readTrajectory, TOOL_CALLS_FILENAME } from "./trajectory.js";
|
|
43
|
+
import { isLowerSha256 } from "./execution-identity.js";
|
|
44
|
+
import {
|
|
45
|
+
ContractError, productionIdentifier, productionTurnIdentifier,
|
|
46
|
+
} from "./wire.js";
|
|
47
|
+
|
|
48
|
+
export const TURNS_FILENAME = "turns.jsonl";
|
|
49
|
+
export const CONVERSATION_ID_FILENAME = "conversation_id";
|
|
50
|
+
export const TASK_ID_FILENAME = "task_id";
|
|
51
|
+
export const TASK_ID_CONFLICT_FILENAME = "task_id.conflict";
|
|
52
|
+
export const AGENT_FINGERPRINT_FILENAME = "agent_fingerprint.json";
|
|
53
|
+
export const AGENT_FINGERPRINT_CONFLICT_FILENAME = "agent_fingerprint.conflict";
|
|
54
|
+
|
|
55
|
+
/** The two halves of a turn. Anything else is a programming error. */
|
|
56
|
+
export const TURN_KINDS = ["task", "answer"];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Matches Python's `record_turn_text(max_chars=20_000)`.
|
|
60
|
+
*
|
|
61
|
+
* A cap rather than the whole message, because this is a customer's end user's
|
|
62
|
+
* text and a runaway paste should not put a megabyte per turn on their disk.
|
|
63
|
+
* The same number on both sides, so a turn captured by either package
|
|
64
|
+
* truncates at the same point and the two corpora are comparable.
|
|
65
|
+
*
|
|
66
|
+
* CHARS MEANS CODE POINTS, which is what `truncateChars` below exists to make
|
|
67
|
+
* true -- see it for why `slice` was not that.
|
|
68
|
+
*/
|
|
69
|
+
export const MAX_TEXT_CHARS = 20_000;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `text[:MAX_TEXT_CHARS]` as PYTHON means it: code POINTS, not UTF-16 units.
|
|
73
|
+
*
|
|
74
|
+
* `String.prototype.slice` counts UTF-16 code units, so it truncated a
|
|
75
|
+
* mostly-emoji message at HALF the documented point -- the two corpora were
|
|
76
|
+
* not comparable, which is the one thing the shared constant is for. Worse, a
|
|
77
|
+
* cut landing between the halves of a surrogate pair leaves a LONE SURROGATE,
|
|
78
|
+
* which `JSON.stringify` writes into `turns.jsonl` and onto the wire; the
|
|
79
|
+
* intake cannot encode it to UTF-8, the chunk is never acknowledged, and
|
|
80
|
+
* `createTurnUploader.flush` leaves every marker in that chunk unwritten, so
|
|
81
|
+
* the poisoned turn is re-sent on every flush forever. Iterating the string
|
|
82
|
+
* yields whole code points, so neither is possible.
|
|
83
|
+
*
|
|
84
|
+
* The length guard is not an optimisation of the general case, it IS the
|
|
85
|
+
* common case: code units are never fewer than code points, so text under the
|
|
86
|
+
* cap in units is under it in points and never needs to be split at all.
|
|
87
|
+
*/
|
|
88
|
+
function truncateChars(text) {
|
|
89
|
+
if (text.length <= MAX_TEXT_CHARS) return text;
|
|
90
|
+
return [...text].slice(0, MAX_TEXT_CHARS).join("");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One turn's directory. The directory name IS the turn id. */
|
|
94
|
+
export function turnDir(root, turnId) {
|
|
95
|
+
const exact = safeTurnId(turnId);
|
|
96
|
+
if (exact === null) {
|
|
97
|
+
throw new ContractError("turn id does not match the production identifier grammar");
|
|
98
|
+
}
|
|
99
|
+
return join(root, exact);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A turn id is host-supplied, and it becomes a path segment.
|
|
104
|
+
*
|
|
105
|
+
* Valid opaque ids are preserved byte-for-byte. Invalid values return null;
|
|
106
|
+
* the host adapter can then mint a fresh id for that run rather than silently
|
|
107
|
+
* cleaning two different caller ids into the same directory name.
|
|
108
|
+
*/
|
|
109
|
+
export function safeTurnId(value) {
|
|
110
|
+
return productionTurnIdentifier(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Append one half of a turn. Returns whether anything was written.
|
|
115
|
+
*
|
|
116
|
+
* NEVER THROWS. This runs inside a customer's production request, and the
|
|
117
|
+
* standing rule is that a capture bug costs the observation and never the
|
|
118
|
+
* turn.
|
|
119
|
+
*
|
|
120
|
+
* EMPTY TEXT WRITES NOTHING, rather than writing an empty record: "the agent
|
|
121
|
+
* answered with silence" and "we were not given the answer" are different
|
|
122
|
+
* claims, and only the second is true when there is no text. Collapsing them
|
|
123
|
+
* would put an empty `final_text` on the wire, which the intake would accept
|
|
124
|
+
* as a real -- and terrible -- answer.
|
|
125
|
+
*/
|
|
126
|
+
export function recordTurnText(dir, kind, text, ids = {}) {
|
|
127
|
+
if (!TURN_KINDS.includes(kind)) {
|
|
128
|
+
throw new Error(`turn kind must be one of ${TURN_KINDS}, got ${kind}`);
|
|
129
|
+
}
|
|
130
|
+
if (typeof text !== "string" || !text.trim()) return false;
|
|
131
|
+
const record = { kind, text: truncateChars(text.trim()) };
|
|
132
|
+
for (const [k, v] of Object.entries(ids)) {
|
|
133
|
+
if (typeof v === "string" && v) record[k] = v;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(dir, { recursive: true });
|
|
137
|
+
appendFileSync(join(dir, TURNS_FILENAME),
|
|
138
|
+
JSON.stringify(record) + "\n", "utf8");
|
|
139
|
+
return true;
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Record which conversation this turn belongs to. Idempotent, never throws. */
|
|
146
|
+
export function recordConversationId(dir, conversationId) {
|
|
147
|
+
const exact = productionIdentifier(conversationId);
|
|
148
|
+
if (exact === null) return false;
|
|
149
|
+
try {
|
|
150
|
+
mkdirSync(dir, { recursive: true });
|
|
151
|
+
writeFileSync(join(dir, CONVERSATION_ID_FILENAME),
|
|
152
|
+
exact, "utf8");
|
|
153
|
+
return true;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Persist one caller-authored task/case correlation without relabelling a
|
|
161
|
+
* mixed turn. This is neutral evidence only; it grants no training meaning.
|
|
162
|
+
*/
|
|
163
|
+
export function recordTaskId(dir, taskId) {
|
|
164
|
+
const exact = productionIdentifier(taskId);
|
|
165
|
+
if (exact === null) return false;
|
|
166
|
+
const sidecar = join(dir, TASK_ID_FILENAME);
|
|
167
|
+
const conflict = join(dir, TASK_ID_CONFLICT_FILENAME);
|
|
168
|
+
try {
|
|
169
|
+
mkdirSync(dir, { recursive: true });
|
|
170
|
+
if (existsSync(conflict)) return false;
|
|
171
|
+
try {
|
|
172
|
+
writeFileSync(sidecar, exact, { encoding: "utf8", flag: "wx" });
|
|
173
|
+
return true;
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err?.code !== "EEXIST") return false;
|
|
176
|
+
}
|
|
177
|
+
let current = null;
|
|
178
|
+
try {
|
|
179
|
+
current = readFileSync(sidecar, "utf8");
|
|
180
|
+
} catch {
|
|
181
|
+
current = null;
|
|
182
|
+
}
|
|
183
|
+
if (current === exact) return true;
|
|
184
|
+
try {
|
|
185
|
+
writeFileSync(conflict, "", { encoding: "utf8", flag: "wx" });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (err?.code !== "EEXIST") return false;
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Persist an SDK-reconciled identity without letting a second writer relabel
|
|
197
|
+
* a mixed turn. A conflict marker is monotonic and makes every reader abstain.
|
|
198
|
+
*/
|
|
199
|
+
export function recordExecutionFingerprint(dir, fingerprint) {
|
|
200
|
+
const digest = fingerprint?.execution_sha256;
|
|
201
|
+
if (!isLowerSha256(digest)) return false;
|
|
202
|
+
const expected = { execution_sha256: digest };
|
|
203
|
+
const sidecar = join(dir, AGENT_FINGERPRINT_FILENAME);
|
|
204
|
+
const conflict = join(dir, AGENT_FINGERPRINT_CONFLICT_FILENAME);
|
|
205
|
+
try {
|
|
206
|
+
mkdirSync(dir, { recursive: true });
|
|
207
|
+
if (existsSync(conflict)) return false;
|
|
208
|
+
try {
|
|
209
|
+
writeFileSync(sidecar, JSON.stringify(expected), { encoding: "utf8", flag: "wx" });
|
|
210
|
+
return true;
|
|
211
|
+
} catch (err) {
|
|
212
|
+
if (err?.code !== "EEXIST") return false;
|
|
213
|
+
}
|
|
214
|
+
let current = null;
|
|
215
|
+
try {
|
|
216
|
+
current = JSON.parse(readFileSync(sidecar, "utf8"));
|
|
217
|
+
} catch {
|
|
218
|
+
current = null;
|
|
219
|
+
}
|
|
220
|
+
if (
|
|
221
|
+
current && typeof current === "object" && !Array.isArray(current)
|
|
222
|
+
&& Object.keys(current).length === 1
|
|
223
|
+
&& current.execution_sha256 === digest
|
|
224
|
+
) return true;
|
|
225
|
+
try {
|
|
226
|
+
writeFileSync(conflict, "", { encoding: "utf8", flag: "wx" });
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if (err?.code !== "EEXIST") return false;
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Permanently invalidate a turn whose observations disagree. */
|
|
237
|
+
export function recordExecutionFingerprintConflict(dir) {
|
|
238
|
+
try {
|
|
239
|
+
mkdirSync(dir, { recursive: true });
|
|
240
|
+
try {
|
|
241
|
+
writeFileSync(join(dir, AGENT_FINGERPRINT_CONFLICT_FILENAME), "", {
|
|
242
|
+
encoding: "utf8", flag: "wx",
|
|
243
|
+
});
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (err?.code !== "EEXIST") return false;
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
} catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Read only the exact sidecar shape this SDK authors. */
|
|
254
|
+
export function readExecutionFingerprint(dir) {
|
|
255
|
+
const conflict = join(dir, AGENT_FINGERPRINT_CONFLICT_FILENAME);
|
|
256
|
+
if (existsSync(conflict)) return null;
|
|
257
|
+
let value;
|
|
258
|
+
try {
|
|
259
|
+
value = JSON.parse(readFileSync(join(dir, AGENT_FINGERPRINT_FILENAME), "utf8"));
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
if (
|
|
264
|
+
!value || typeof value !== "object" || Array.isArray(value)
|
|
265
|
+
|| Object.keys(value).length !== 1
|
|
266
|
+
|| !isLowerSha256(value.execution_sha256)
|
|
267
|
+
|| existsSync(conflict)
|
|
268
|
+
) return null;
|
|
269
|
+
return { execution_sha256: value.execution_sha256 };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The conversation this turn declared, or `null`. */
|
|
273
|
+
export function readConversationId(dir) {
|
|
274
|
+
try {
|
|
275
|
+
const raw = readFileSync(join(dir, CONVERSATION_ID_FILENAME), "utf8");
|
|
276
|
+
return productionIdentifier(raw) === raw ? raw : null;
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Read an exact, non-conflicting task binding, or null. */
|
|
283
|
+
export function readTaskId(dir) {
|
|
284
|
+
const conflict = join(dir, TASK_ID_CONFLICT_FILENAME);
|
|
285
|
+
if (existsSync(conflict)) return null;
|
|
286
|
+
let raw;
|
|
287
|
+
try {
|
|
288
|
+
raw = readFileSync(join(dir, TASK_ID_FILENAME), "utf8");
|
|
289
|
+
} catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
return productionIdentifier(raw) === raw && !existsSync(conflict) ? raw : null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The turn's task and answer, or `null` when nothing was recorded.
|
|
297
|
+
*
|
|
298
|
+
* THE LAST RECORD OF EACH KIND WINS, matching Python. A turn that was answered
|
|
299
|
+
* twice -- a retry, a correction -- ends with the answer that was actually
|
|
300
|
+
* returned, not the one that was superseded.
|
|
301
|
+
*
|
|
302
|
+
* `null` (no file) is not the same as `{}` (a file recording neither half),
|
|
303
|
+
* and neither is collapsed, for the same reason the tool-call tri-state is
|
|
304
|
+
* not.
|
|
305
|
+
*/
|
|
306
|
+
export function readTurnText(dir) {
|
|
307
|
+
let raw;
|
|
308
|
+
try {
|
|
309
|
+
raw = readFileSync(join(dir, TURNS_FILENAME), "utf8");
|
|
310
|
+
} catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
const out = {};
|
|
314
|
+
for (const line of raw.split("\n")) {
|
|
315
|
+
if (!line.trim()) continue;
|
|
316
|
+
try {
|
|
317
|
+
const rec = JSON.parse(line);
|
|
318
|
+
if (TURN_KINDS.includes(rec?.kind) && typeof rec.text === "string") {
|
|
319
|
+
out[rec.kind] = rec.text;
|
|
320
|
+
}
|
|
321
|
+
} catch {
|
|
322
|
+
// A truncated final write costs that record and nothing else -- the same
|
|
323
|
+
// per-line recovery the tool-call reader does.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* One captured turn in the intake's wire shape, or `null` if it is empty.
|
|
331
|
+
*
|
|
332
|
+
* TURN-NATIVE, NOT CORPUS-SHAPED. What crosses the boundary is what the agent
|
|
333
|
+
* observed -- the user's message, the answer, the calls. Projecting into a
|
|
334
|
+
* training-row format happens on the control plane's side, where it can change
|
|
335
|
+
* without a customer redeploying.
|
|
336
|
+
*
|
|
337
|
+
* THE TOOL-CALL TRI-STATE is asked of the FILE, not of whether this plugin
|
|
338
|
+
* happens to be recording. Python had a bug here worth not repeating: it
|
|
339
|
+
* answered `[] if adapters_bound else None`, which reports on whether capture
|
|
340
|
+
* was configured at attach time and says nothing about whether THIS turn
|
|
341
|
+
* recorded anything -- so a turn whose trajectory was never written went up as
|
|
342
|
+
* a positive assertion that the agent made zero calls. An observation nobody
|
|
343
|
+
* made.
|
|
344
|
+
*
|
|
345
|
+
* rows present -> the calls
|
|
346
|
+
* file present, no rows -> [] (it ran and called nothing)
|
|
347
|
+
* file absent -> null (nothing was recorded for it)
|
|
348
|
+
*/
|
|
349
|
+
export function turnWire(dir, { turnIndex = 0 } = {}) {
|
|
350
|
+
const turnId = productionTurnIdentifier(basename(dir));
|
|
351
|
+
if (turnId === null) {
|
|
352
|
+
throw new ContractError(
|
|
353
|
+
"captured turn id is not representable on the flywheel/1 wire",
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
const text = readTurnText(dir) || {};
|
|
357
|
+
const { calls, malformed } = readTrajectory(dir);
|
|
358
|
+
const answer = text.answer ?? null;
|
|
359
|
+
const task = text.task ?? null;
|
|
360
|
+
const taskId = readTaskId(dir);
|
|
361
|
+
|
|
362
|
+
let toolCalls;
|
|
363
|
+
if (calls && calls.length) toolCalls = calls;
|
|
364
|
+
else if (existsSync(join(dir, TOOL_CALLS_FILENAME))) toolCalls = [];
|
|
365
|
+
else toolCalls = null;
|
|
366
|
+
|
|
367
|
+
if (!task && !answer && !(toolCalls && toolCalls.length) && !taskId) return null;
|
|
368
|
+
|
|
369
|
+
const wire = {
|
|
370
|
+
// `basename`, not `split("/")`. The turn id is the DIRECTORY NAME, and on
|
|
371
|
+
// win32 `join` builds paths with `\`, so splitting on a hardcoded "/"
|
|
372
|
+
// returned the whole absolute path: an operator's home directory went onto
|
|
373
|
+
// the wire as the turn id, and it could never match the bare `d.name` that
|
|
374
|
+
// `createTurnUploader` writes into `.uploaded` -- so no turn was ever
|
|
375
|
+
// considered uploaded and every flush re-sent the entire capture root.
|
|
376
|
+
turn_id: turnId,
|
|
377
|
+
input_text: task,
|
|
378
|
+
final_text: answer,
|
|
379
|
+
tool_calls: toolCalls,
|
|
380
|
+
tool_calls_omitted_count: malformed,
|
|
381
|
+
};
|
|
382
|
+
const cid = readConversationId(dir);
|
|
383
|
+
if (cid) {
|
|
384
|
+
wire.conversation_id = cid;
|
|
385
|
+
wire.turn_index = turnIndex;
|
|
386
|
+
}
|
|
387
|
+
if (taskId) wire.task_id = taskId;
|
|
388
|
+
const fingerprint = readExecutionFingerprint(dir);
|
|
389
|
+
if (fingerprint !== null) wire.agent_fingerprint = fingerprint;
|
|
390
|
+
return wire;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Every turn directory under `root`, oldest first.
|
|
395
|
+
*
|
|
396
|
+
* ORDERED BY MTIME, which is the ordering Python derives `turn_index` from.
|
|
397
|
+
* Both ends must agree, because the control plane indexes on
|
|
398
|
+
* `(org, agent, conversation_id, turn_index)` and a conversation whose turns
|
|
399
|
+
* all claim index 0 cannot be ordered at all.
|
|
400
|
+
*/
|
|
401
|
+
export function listTurnDirs(root) {
|
|
402
|
+
let names;
|
|
403
|
+
try {
|
|
404
|
+
names = readdirSync(root);
|
|
405
|
+
} catch {
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
const dirs = [];
|
|
409
|
+
for (const name of names) {
|
|
410
|
+
const path = join(root, name);
|
|
411
|
+
try {
|
|
412
|
+
const st = statSync(path);
|
|
413
|
+
if (st.isDirectory()) dirs.push({ path, name, mtime: st.mtimeMs });
|
|
414
|
+
} catch {
|
|
415
|
+
// Vanished between listing and stat: a concurrent prune. Not an error.
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
dirs.sort((a, b) => a.mtime - b.mtime || a.name.localeCompare(b.name));
|
|
419
|
+
return dirs;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* This turn's position within its conversation.
|
|
424
|
+
*
|
|
425
|
+
* Computed over every turn of that conversation ON DISK, not just the ones
|
|
426
|
+
* being uploaded now, so a turn sent in a later batch keeps the position it
|
|
427
|
+
* actually had. A counter held in memory would restart at zero on every
|
|
428
|
+
* process restart -- and every turn landing at index 0 is precisely what the
|
|
429
|
+
* control plane's index exists to prevent.
|
|
430
|
+
*/
|
|
431
|
+
export function turnIndexOf(root, dirName, conversationId) {
|
|
432
|
+
if (!conversationId) return 0;
|
|
433
|
+
const siblings = listTurnDirs(root)
|
|
434
|
+
.filter((d) => readConversationId(d.path) === conversationId);
|
|
435
|
+
const i = siblings.findIndex((d) => d.name === dirName);
|
|
436
|
+
return i < 0 ? 0 : i;
|
|
437
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UNATTENDED ROLLOUTS: the plugin starts the turn itself.
|
|
3
|
+
*
|
|
4
|
+
* WHAT TRAINING MODE DOES. There is no switch of its own here, and there were
|
|
5
|
+
* two wrong answers before this one.
|
|
6
|
+
*
|
|
7
|
+
* First it was opt-in, on the argument that this reaches past a restriction
|
|
8
|
+
* the host states for its own scheduling API. That treated "the plugin runs
|
|
9
|
+
* the rollout" as something beyond what was asked for, and it is not:
|
|
10
|
+
* `PERCEPTEYE_AGENT_MODE=training` already means "this install claims rollouts
|
|
11
|
+
* and runs them", and in that mode the plugin already claims work and puts
|
|
12
|
+
* tasks into the agent's context. Starting the turn is how that promise is
|
|
13
|
+
* kept, so opt-in only meant the flywheel sat idle waiting to be enabled.
|
|
14
|
+
*
|
|
15
|
+
* Then it was default-on with its own kill switch, which was the same mistake
|
|
16
|
+
* one step smaller: `PERCEPTEYE_AGENT_MODE` already answers "does this install
|
|
17
|
+
* drive?", so a second flag was a second answer to a settled question. The one
|
|
18
|
+
* state it could have named -- claim rollouts but wait for a human to start
|
|
19
|
+
* each turn -- has no customer behind it. An operator who wants the agent left
|
|
20
|
+
* alone sets `production`, and gets a stronger guarantee than a flag: this
|
|
21
|
+
* module is never even reached.
|
|
22
|
+
*
|
|
23
|
+
* The real risk is handled below by DEGRADING, not by a switch: a flag never
|
|
24
|
+
* made the host seam more stable, it only meant somebody had to know about it.
|
|
25
|
+
*
|
|
26
|
+
* ── WHAT IT REACHES PAST, AND WHY THAT IS SURVIVABLE ──────────────────────
|
|
27
|
+
*
|
|
28
|
+
* OpenClaw's documented API for starting a turn from a plugin,
|
|
29
|
+
* `api.session.workflow.scheduleSessionTurn`, is restricted to plugins bundled
|
|
30
|
+
* with the host: `schedulePluginSessionTurn` returns immediately for any other
|
|
31
|
+
* origin (registry-B8eQDFB4.js:1186), silently.
|
|
32
|
+
*
|
|
33
|
+
* The capability underneath is not restricted. `gateway_start` and
|
|
34
|
+
* `cron_changed` hand every plugin the raw host cron service as
|
|
35
|
+
* `ctx.getCron()` (server-startup-post-attach-B3O9knW5.js:765,
|
|
36
|
+
* server-cron-Cwg2hJro.js:4384); `registerTypedHook` gates conversation hooks
|
|
37
|
+
* by origin and prompt-injection hooks by policy, and `gateway_start` is in
|
|
38
|
+
* neither set; and `server-cron` carries no origin check at all. A job with
|
|
39
|
+
* `payload.kind: "agentTurn"` reaches `runIsolatedAgentJob` (:2433) and
|
|
40
|
+
* `runCronIsolatedAgentTurn` (:4484), which runs the agent. The job built
|
|
41
|
+
* below is deliberately the same shape the bundled-only wrapper builds
|
|
42
|
+
* (registry-B8eQDFB4.js:1240-1254).
|
|
43
|
+
*
|
|
44
|
+
* So this reaches past a restriction the host states explicitly, by way of a
|
|
45
|
+
* service it hands out elsewhere. That may be an inconsistency rather than an
|
|
46
|
+
* intention, which means it can be closed in a patch release. Two consequences
|
|
47
|
+
* are designed in rather than discovered later:
|
|
48
|
+
*
|
|
49
|
+
* 1. It is confined to TRAINING mode, structurally. A process serving real
|
|
50
|
+
* users never reaches this module, whatever the setting says.
|
|
51
|
+
* 2. It DEGRADES to the default lane instead of failing. If `getCron()`
|
|
52
|
+
* returns nothing -- the seam closed, or cron is simply unavailable --
|
|
53
|
+
* the plugin says so once, STOPS POLLING, and leaves the `session_start`
|
|
54
|
+
* injection lane as the only claimer. A capability that disappears must
|
|
55
|
+
* not take rollouts down with it.
|
|
56
|
+
*
|
|
57
|
+
* STOPPING IS THE WHOLE OF THE DEGRADATION, and the first version of it
|
|
58
|
+
* left that part out: it announced the fallback, handed the rollout
|
|
59
|
+
* back, and then polled again `pollMs` later, forever. Mission Control's
|
|
60
|
+
* `claim` increments `attempt`, and its `abandon` requeues only while
|
|
61
|
+
* `attempt < max_attempts` -- so a runner that claims and hands back
|
|
62
|
+
* every 30 seconds walks the whole org's queue to ABANDONED at three
|
|
63
|
+
* attempts each, having run none of it, while reporting itself as having
|
|
64
|
+
* fallen back. A seam that is gone must make this lane QUIET, not busy.
|
|
65
|
+
*
|
|
66
|
+
* `start()` is the one thing that undoes it, because its only callers
|
|
67
|
+
* are `gateway_start` and `cron_changed` and both mean the host has just
|
|
68
|
+
* handed over a cron context. That keeps a LATER-ARRIVING cron usable
|
|
69
|
+
* (see `cronTurnStarter` below) without reopening the churn: a host with
|
|
70
|
+
* no cron service emits neither event, so nothing restarts the loop.
|
|
71
|
+
*
|
|
72
|
+
* ── ONE ROLLOUT AT A TIME, IN ITS OWN SESSION ─────────────────────────────
|
|
73
|
+
*
|
|
74
|
+
* Each rollout runs under `percepteye-rollout-<id>`, never a session a person
|
|
75
|
+
* is using. Commandeering a human's session to run training work would be
|
|
76
|
+
* indefensible, and an isolated session is also what makes `agent_end`
|
|
77
|
+
* attributable: the key the turn runs under is the key the report is looked up
|
|
78
|
+
* by.
|
|
79
|
+
*
|
|
80
|
+
* Serial by construction -- the runner claims only when nothing is in flight.
|
|
81
|
+
* A concurrency knob would be the second thing to get wrong here, and a
|
|
82
|
+
* customer's gateway is not the place to discover that.
|
|
83
|
+
*/
|
|
84
|
+
import { taskText } from "./rollout.js";
|
|
85
|
+
|
|
86
|
+
export const JOB_PREFIX = "percepteye:rollout:";
|
|
87
|
+
|
|
88
|
+
/** How often to look for work when idle. */
|
|
89
|
+
export const DEFAULT_POLL_MS = 30_000;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Turn the raw cron service into "start a turn", or say why it cannot.
|
|
93
|
+
*
|
|
94
|
+
* `getCron` is a FUNCTION, not a service, because the context that carries it
|
|
95
|
+
* only exists during `gateway_start`/`cron_changed` and the service may not be
|
|
96
|
+
* up yet at that moment -- the bundled memory-core plugin has the same problem
|
|
97
|
+
* and defers the same way. Resolving it per call keeps a later-arriving cron
|
|
98
|
+
* usable instead of caching an `undefined` forever.
|
|
99
|
+
*/
|
|
100
|
+
export function cronTurnStarter(getCron) {
|
|
101
|
+
return async function startTurn({ sessionKey, message, rolloutId }) {
|
|
102
|
+
const cron = typeof getCron === "function" ? getCron() : null;
|
|
103
|
+
if (!cron || typeof cron.add !== "function") {
|
|
104
|
+
return {
|
|
105
|
+
started: false,
|
|
106
|
+
degraded: true,
|
|
107
|
+
reason:
|
|
108
|
+
"the host cron service is unavailable to this plugin, so no turn " +
|
|
109
|
+
"can be started",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await cron.add({
|
|
114
|
+
name: `${JOB_PREFIX}${rolloutId}`,
|
|
115
|
+
// Idempotent per rollout: a retried claim of the same work cannot
|
|
116
|
+
// queue a second turn for it.
|
|
117
|
+
declarationKey: `${JOB_PREFIX}${rolloutId}`,
|
|
118
|
+
enabled: true,
|
|
119
|
+
// One-shot, effectively now. `at` is what the host's own wrapper uses
|
|
120
|
+
// for a `delayMs`, clamped to at least 1ms.
|
|
121
|
+
schedule: { kind: "at", at: new Date(Date.now() + 1).toISOString() },
|
|
122
|
+
sessionTarget: `session:${sessionKey}`,
|
|
123
|
+
payload: { kind: "agentTurn", message },
|
|
124
|
+
deleteAfterRun: true,
|
|
125
|
+
wakeMode: "now",
|
|
126
|
+
// NOT "announce". Announcing would deliver the rollout's output into
|
|
127
|
+
// whatever chat the customer last used, which is training work
|
|
128
|
+
// appearing in a human's conversation.
|
|
129
|
+
delivery: { mode: "none" },
|
|
130
|
+
});
|
|
131
|
+
return { started: true, degraded: false, reason: null };
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return {
|
|
134
|
+
started: false,
|
|
135
|
+
// NOT degraded: cron answered and refused. Falling back to the
|
|
136
|
+
// injection lane on a genuine rejection would hide a real error behind
|
|
137
|
+
// a quieter mode.
|
|
138
|
+
degraded: false,
|
|
139
|
+
reason: `the host refused the rollout turn: ${err?.message ?? err}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The session a rollout runs in. Never a session a person is using. */
|
|
146
|
+
export const sessionKeyFor = (rolloutId) => `percepteye-rollout-${rolloutId}`;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Claim, start, and keep going -- one rollout at a time.
|
|
150
|
+
*
|
|
151
|
+
* `driver` is the same one the attended lane uses, so reporting and typed
|
|
152
|
+
* abandon happen in exactly one place for both lanes.
|
|
153
|
+
*/
|
|
154
|
+
export function createUnattendedRunner({
|
|
155
|
+
transport, driver, startTurn, logger = null, pollMs = DEFAULT_POLL_MS,
|
|
156
|
+
}) {
|
|
157
|
+
let timer = null;
|
|
158
|
+
let running = false;
|
|
159
|
+
/**
|
|
160
|
+
* The turn-starting seam is gone, so this lane claims nothing.
|
|
161
|
+
*
|
|
162
|
+
* ONE FLAG, NOT TWO. It used to be `degradedAnnounced`, gating only the
|
|
163
|
+
* warning, which meant the loop went on claiming and handing back -- see the
|
|
164
|
+
* header. "Have we said it" and "should we still claim" are the same
|
|
165
|
+
* question here: the announcement IS the fallback taking effect.
|
|
166
|
+
*/
|
|
167
|
+
let degraded = false;
|
|
168
|
+
|
|
169
|
+
function stopTimer() {
|
|
170
|
+
if (timer) clearInterval(timer);
|
|
171
|
+
timer = null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function tick() {
|
|
175
|
+
// Never claim once the seam is known dead: a claim we cannot start still
|
|
176
|
+
// costs the rollout an attempt, and three of those retire it unrun.
|
|
177
|
+
// Checked HERE rather than in `start()` alone, so a directly driven tick
|
|
178
|
+
// cannot walk around it either.
|
|
179
|
+
if (degraded || running || driver.inFlight.size > 0) return null;
|
|
180
|
+
running = true;
|
|
181
|
+
try {
|
|
182
|
+
let claimed;
|
|
183
|
+
try {
|
|
184
|
+
claimed = await transport.claim(1);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
logger?.warn?.(
|
|
187
|
+
`[agent-flywheel] could not claim a rollout: ${err?.message ?? err}`);
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const rollout = claimed?.rollouts?.[0];
|
|
191
|
+
if (!rollout) return null;
|
|
192
|
+
|
|
193
|
+
const sessionKey = sessionKeyFor(rollout.rolloutId);
|
|
194
|
+
driver.track(sessionKey, rollout);
|
|
195
|
+
|
|
196
|
+
const result = await startTurn({
|
|
197
|
+
sessionKey, rolloutId: rollout.rolloutId, message: taskText(rollout),
|
|
198
|
+
});
|
|
199
|
+
if (result.started) {
|
|
200
|
+
logger?.info?.(
|
|
201
|
+
`[agent-flywheel] rollout ${rollout.rolloutId} started unattended in ` +
|
|
202
|
+
`session ${sessionKey}.`);
|
|
203
|
+
return rollout;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The turn will never run, so the work goes back NOW rather than sitting
|
|
207
|
+
// until its lease lapses.
|
|
208
|
+
await driver.giveBack(
|
|
209
|
+
sessionKey, rollout, "entrypoint_error", result.reason);
|
|
210
|
+
if (result.degraded) {
|
|
211
|
+
degraded = true;
|
|
212
|
+
// The fallback, actually taken: stop looking for work. Anything else
|
|
213
|
+
// spends one rollout's attempt per poll on a turn that cannot start.
|
|
214
|
+
stopTimer();
|
|
215
|
+
logger?.warn?.(
|
|
216
|
+
`[agent-flywheel] unattended rollouts are configured but ${result.reason}. ` +
|
|
217
|
+
`Falling back to the default lane: this plugin has stopped polling ` +
|
|
218
|
+
`for rollouts and tasks are delivered into the next turn you start. ` +
|
|
219
|
+
`Nothing else changes.`);
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
} finally {
|
|
223
|
+
running = false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
tick,
|
|
229
|
+
/**
|
|
230
|
+
* Is this lane still looking for work?
|
|
231
|
+
*
|
|
232
|
+
* The observable half of the degradation. `tick`'s own guard is what stops
|
|
233
|
+
* the claiming, and a test that only counts claims passes just as happily
|
|
234
|
+
* with `stopTimer()` deleted -- leaving a poll interval waking every 30
|
|
235
|
+
* seconds forever to return immediately, in a customer's gateway, in a
|
|
236
|
+
* lane the operator was told had gone quiet.
|
|
237
|
+
*/
|
|
238
|
+
get polling() { return timer !== null; },
|
|
239
|
+
start() {
|
|
240
|
+
// Only `gateway_start` and `cron_changed` call this, and both mean the
|
|
241
|
+
// host has just handed over a cron context -- the one event that can
|
|
242
|
+
// undo a degradation, and the only thing that restarts the loop.
|
|
243
|
+
degraded = false;
|
|
244
|
+
if (timer) return;
|
|
245
|
+
timer = setInterval(() => { tick().catch(() => {}); }, pollMs);
|
|
246
|
+
// Never hold a customer's gateway open on our account.
|
|
247
|
+
timer.unref?.();
|
|
248
|
+
},
|
|
249
|
+
stop: stopTimer,
|
|
250
|
+
};
|
|
251
|
+
}
|