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