create-principles-disciple 1.145.43 → 1.146.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/_release/product-identity.json +2 -2
- package/console/dist/server/routes/examiner-executor.d.ts +8 -2
- package/console/dist/server/routes/examiner-executor.js +10 -4
- package/console/dist/server/routes/examiner.d.ts +23 -17
- package/console/dist/server/routes/examiner.js +30 -27
- package/console/dist/ui/App.js +1 -1
- package/console/dist/ui/components/layout/app-sidebar.d.ts +3 -4
- package/console/dist/ui/components/layout/app-sidebar.js +5 -7
- package/console/dist/ui/i18n/en.json +1 -1
- package/console/dist/ui/i18n/zh-CN.json +1 -1
- package/console/dist/ui/utils/runtime-mode.d.ts +2 -1
- package/console/dist/ui/utils/runtime-mode.js +2 -1
- package/console/dist/web/assets/app.js +23 -23
- package/console/lab/lib/challenge-actor.mjs +394 -0
- package/console/lab/lib/challenge-arms.mjs +102 -0
- package/console/lab/lib/challenge-behavior-report.mjs +2484 -0
- package/console/lab/lib/challenge-compile.mjs +211 -0
- package/console/lab/lib/challenge-freeze.mjs +215 -0
- package/console/lab/lib/challenge-isolation-probe.mjs +550 -0
- package/console/lab/lib/challenge-ledger.mjs +206 -0
- package/console/lab/lib/challenge-openclaw-host.mjs +630 -0
- package/console/lab/lib/challenge-runner.mjs +868 -0
- package/console/lab/lib/challenge-stage-r-gate.mjs +458 -0
- package/console/lab/lib/challenge-statistics.mjs +591 -0
- package/console/lab/lib/claim-evidence-env.mjs +652 -0
- package/console/lab/lib/claim-evidence-scorer.mjs +784 -0
- package/console/lab/lib/examiner-injection-evidence.mjs +367 -0
- package/console/lab/lib/examiner-study.mjs +1203 -0
- package/console/lab/lib/examiner-v07-cases.mjs +187 -0
- package/console/lab/lib/examiner-v2-cases.mjs +61 -0
- package/console/lab/lib/fixture.mjs +382 -0
- package/console/lab/lib/g2.mjs +146 -0
- package/console/lab/lib/host-actor.mjs +987 -0
- package/console/lab/lib/sidecar.mjs +273 -0
- package/package.json +1 -1
- package/release-manager/package.json +1 -1
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
// PRI-966 G2 — Codex HOST transport for the real-actor study (host-native Luna).
|
|
2
|
+
//
|
|
3
|
+
// This is the HOST-NATIVE execution path, deliberately distinct from the
|
|
4
|
+
// pi-ai EXTERNAL path in lib/actor.mjs:
|
|
5
|
+
//
|
|
6
|
+
// external (actor.mjs): PD owns every round through PiAiRuntimeAdapter +
|
|
7
|
+
// budgetedFetch — an HTTP/USD fail-closed ledger IS the cost boundary.
|
|
8
|
+
// host (this file): the model executes inside the OWNER'S Codex CLI. PD
|
|
9
|
+
// only spawns the official headless interface (`codex exec --json`,
|
|
10
|
+
// then `codex exec resume <session-id>`) as a research-side subprocess.
|
|
11
|
+
//
|
|
12
|
+
// Because host calls never cross PD's fetch boundary, the P1-01 HTTP ledger
|
|
13
|
+
// and the USD cost guard DO NOT APPLY here and MUST NOT be simulated. The
|
|
14
|
+
// enforceable boundary is the PROCESS boundary: every spawn is reserved
|
|
15
|
+
// against a hierarchical process ledger (opportunity → arm → study) BEFORE
|
|
16
|
+
// the child exists; token totals are OBSERVED from the host's own event
|
|
17
|
+
// stream (token_count) and stop the NEXT spawn when exceeded — they can never
|
|
18
|
+
// abort a round already executing on the host. Both facts are recorded as
|
|
19
|
+
// such: exec/process caps are hard pre-send limits, token totals are
|
|
20
|
+
// observed feedback limits (rc-9 — no silent, no mislabeled guarantees).
|
|
21
|
+
//
|
|
22
|
+
// Session discipline (per prereg deliveryInvariance/actorIsolation):
|
|
23
|
+
// - ONE host session per OPPORTUNITY (rounds within an opportunity share
|
|
24
|
+
// the session so observations accumulate; a new opportunity starts a
|
|
25
|
+
// fresh session so no task text leaks across opportunities);
|
|
26
|
+
// - the session id comes ONLY from the host's own event stream
|
|
27
|
+
// (thread_settings_applied / item_completed / token_count thread_id) —
|
|
28
|
+
// `resume --last` is FORBIDDEN (shared CODEX_HOME means "last" can pick
|
|
29
|
+
// a user session); without a session id the next round fails loudly;
|
|
30
|
+
// - per-arm scratch root is the spawn cwd (-C): no shared working state.
|
|
31
|
+
//
|
|
32
|
+
// Model identity (task §3.2): requested `-m` vs the host's OWN report
|
|
33
|
+
// (thread_settings_applied.thread_settings.model). Mismatch or absence is
|
|
34
|
+
// never papered over: the snapshot carries MODEL_IDENTITY_MISMATCH /
|
|
35
|
+
// MODEL_IDENTITY_UNVERIFIED and the runner must exclude such arms from
|
|
36
|
+
// formal results (dataQuality UNCERTAIN). An offline spawn-stub test proves
|
|
37
|
+
// the wiring, NOT connectivity with a real model.
|
|
38
|
+
|
|
39
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
40
|
+
import fs from 'node:fs';
|
|
41
|
+
import os from 'node:os';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
|
|
44
|
+
/** Thrown BEFORE a child process is created when any ledger level refuses.
|
|
45
|
+
* Carries the refusing scope and its observed/cap values for audit. */
|
|
46
|
+
export class HostBudgetExhaustedError extends Error {
|
|
47
|
+
constructor({ scope, limit, used, cap, reason }) {
|
|
48
|
+
super(
|
|
49
|
+
`host process budget exhausted at '${scope}' (limit: ${limit}, used: ${JSON.stringify(used)}, cap: ${limit === 'tokens' ? cap : JSON.stringify(cap)}); ` +
|
|
50
|
+
`refusing the NEXT exec spawn (pre-spawn hard limit)${reason ? ` — ${reason}` : ''}`,
|
|
51
|
+
);
|
|
52
|
+
this.name = 'HostBudgetExhaustedError';
|
|
53
|
+
this.code = 'host_budget_exhausted';
|
|
54
|
+
this.scope = scope;
|
|
55
|
+
this.limit = limit;
|
|
56
|
+
this.used = used;
|
|
57
|
+
this.cap = cap;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function nonNegativeInt(value) {
|
|
62
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Hierarchical PROCESS budget ledger for host exec calls. Levels never reset;
|
|
67
|
+
* checks walk the whole ancestor chain (plus the active child) BEFORE any
|
|
68
|
+
* commit. Token/wall-clock totals are observed feedback (checked pre-spawn,
|
|
69
|
+
* recorded post-round); exec and session counts are true pre-spawn hard caps.
|
|
70
|
+
*/
|
|
71
|
+
export function createHostProcessLedger({ scope, caps = {}, parent = null } = {}) {
|
|
72
|
+
if (typeof scope !== 'string' || scope.length === 0) {
|
|
73
|
+
throw new Error('createHostProcessLedger: scope is required');
|
|
74
|
+
}
|
|
75
|
+
if (parent !== null && typeof parent.reserveExec !== 'function') {
|
|
76
|
+
throw new Error('createHostProcessLedger: parent must be a host process ledger');
|
|
77
|
+
}
|
|
78
|
+
const limitCaps = {
|
|
79
|
+
maxExecProcesses: nonNegativeInt(caps.maxExecProcesses),
|
|
80
|
+
maxSessions: nonNegativeInt(caps.maxSessions),
|
|
81
|
+
maxTokens: nonNegativeInt(caps.maxTokens),
|
|
82
|
+
wallMsBudgetMs: nonNegativeInt(caps.wallMsBudgetMs),
|
|
83
|
+
};
|
|
84
|
+
for (const [name, value] of Object.entries(caps)) {
|
|
85
|
+
if (!(name in limitCaps) && value !== undefined) {
|
|
86
|
+
throw new Error(`createHostProcessLedger(${scope}): unknown cap '${name}' — host caps are maxExecProcesses|maxSessions|maxTokens|wallMsBudgetMs`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let activeChild = null;
|
|
90
|
+
const used = { execCount: 0, sessionCount: 0, tokensObserved: 0, wallMsObserved: 0 };
|
|
91
|
+
|
|
92
|
+
const chain = () => {
|
|
93
|
+
const nodes = activeChild !== null ? [activeChild] : [];
|
|
94
|
+
for (let node = ledger; node !== null; node = node.parentScope) nodes.push(node);
|
|
95
|
+
return nodes;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const ledger = {
|
|
99
|
+
scope,
|
|
100
|
+
caps: limitCaps,
|
|
101
|
+
parentScope: parent,
|
|
102
|
+
used() {
|
|
103
|
+
return { ...used };
|
|
104
|
+
},
|
|
105
|
+
setActiveChild(child) {
|
|
106
|
+
if (child !== null && child.parentScope !== ledger) {
|
|
107
|
+
throw new Error(`createHostProcessLedger: setActiveChild expects a direct child of '${scope}'`);
|
|
108
|
+
}
|
|
109
|
+
activeChild = child;
|
|
110
|
+
},
|
|
111
|
+
clearActiveChild() {
|
|
112
|
+
activeChild = null;
|
|
113
|
+
},
|
|
114
|
+
/** Pre-spawn check + commit. `isNewSession` also consumes a session slot. */
|
|
115
|
+
reserveExec({ isNewSession = false, reason = null } = {}) {
|
|
116
|
+
for (const node of chain()) {
|
|
117
|
+
const c = node.caps;
|
|
118
|
+
const u = node.used();
|
|
119
|
+
if (c.maxExecProcesses !== null && u.execCount + 1 > c.maxExecProcesses) {
|
|
120
|
+
throw new HostBudgetExhaustedError({ scope: node.scope, limit: 'exec', used: u, cap: c.maxExecProcesses, reason: reason ?? node.reason });
|
|
121
|
+
}
|
|
122
|
+
if (isNewSession && c.maxSessions !== null && u.sessionCount + 1 > c.maxSessions) {
|
|
123
|
+
throw new HostBudgetExhaustedError({ scope: node.scope, limit: 'sessions', used: u, cap: c.maxSessions, reason: reason ?? node.reason });
|
|
124
|
+
}
|
|
125
|
+
if (c.maxTokens !== null && u.tokensObserved >= c.maxTokens) {
|
|
126
|
+
throw new HostBudgetExhaustedError({
|
|
127
|
+
scope: node.scope, limit: 'tokens', used: u, cap: c.maxTokens,
|
|
128
|
+
reason: 'observed token total already at/over budget — next round not started (the host may still consume tokens for a round already in flight; that is recorded, never hidden)',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (c.wallMsBudgetMs !== null && u.wallMsObserved >= c.wallMsBudgetMs) {
|
|
132
|
+
throw new HostBudgetExhaustedError({ scope: node.scope, limit: 'wallMs', used: u, cap: c.wallMsBudgetMs, reason: reason ?? node.reason });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const node of chain()) {
|
|
136
|
+
node.commitExec(isNewSession);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
/** Post-round observed feedback, committed up the whole chain. */
|
|
140
|
+
recordUsage({ tokensTotal = null, wallMs = 0 } = {}) {
|
|
141
|
+
if (tokensTotal !== null && (!Number.isSafeInteger(tokensTotal) || tokensTotal < 0)) {
|
|
142
|
+
throw new Error(`createHostProcessLedger(${scope}): recordUsage tokensTotal must be a non-negative safe integer or null`);
|
|
143
|
+
}
|
|
144
|
+
for (const node of chain()) {
|
|
145
|
+
node.commitUsage(tokensTotal, wallMs);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
snapshot() {
|
|
149
|
+
const levels = [];
|
|
150
|
+
for (let cur = ledger; cur !== null; cur = cur.parentScope) {
|
|
151
|
+
levels.push({ scope: cur.scope, caps: cur.caps, used: cur.used() });
|
|
152
|
+
}
|
|
153
|
+
return { levels, used: { ...used } };
|
|
154
|
+
},
|
|
155
|
+
// internal
|
|
156
|
+
commitExec(isNewSession) {
|
|
157
|
+
used.execCount += 1;
|
|
158
|
+
if (isNewSession) used.sessionCount += 1;
|
|
159
|
+
},
|
|
160
|
+
commitUsage(tokensTotal, wallMs) {
|
|
161
|
+
if (tokensTotal !== null) used.tokensObserved += tokensTotal;
|
|
162
|
+
used.wallMsObserved += wallMs;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
ledger.reason = caps.reason ?? null;
|
|
166
|
+
return ledger;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const EMPTY_TOKENS = { inputTokens: null, cachedInputTokens: null, cacheWriteInputTokens: null, outputTokens: null, reasoningOutputTokens: null, totalTokens: null };
|
|
170
|
+
|
|
171
|
+
/** Normalize one host usage block (token_count.info.last_token_usage) into
|
|
172
|
+
* lab vocabulary. Missing fields stay null — NEVER zero (rc-3/P1-03). */
|
|
173
|
+
function normalizeUsage(lastTokenUsage) {
|
|
174
|
+
if (lastTokenUsage === null || typeof lastTokenUsage !== 'object') {
|
|
175
|
+
return { ...EMPTY_TOKENS, usageSource: 'unavailable' };
|
|
176
|
+
}
|
|
177
|
+
const pick = (key, altKeys = []) => {
|
|
178
|
+
for (const k of [key, ...altKeys]) {
|
|
179
|
+
const v = lastTokenUsage[k];
|
|
180
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
};
|
|
184
|
+
const normalized = {
|
|
185
|
+
inputTokens: pick('input_tokens'),
|
|
186
|
+
cachedInputTokens: pick('cached_input_tokens'),
|
|
187
|
+
cacheWriteInputTokens: pick('cache_write_input_tokens'),
|
|
188
|
+
outputTokens: pick('output_tokens'),
|
|
189
|
+
reasoningOutputTokens: pick('reasoning_output_tokens'),
|
|
190
|
+
totalTokens: pick('total_tokens'),
|
|
191
|
+
};
|
|
192
|
+
const anyPresent = Object.values(normalized).some((v) => v !== null);
|
|
193
|
+
return { ...normalized, usageSource: anyPresent ? 'host_token_count' : 'unavailable' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function textFromAgentMessage(item) {
|
|
197
|
+
if (item === null || typeof item !== 'object' || item.type !== 'AgentMessage') return null;
|
|
198
|
+
const content = Array.isArray(item.content) ? item.content : [];
|
|
199
|
+
const parts = [];
|
|
200
|
+
for (const c of content) {
|
|
201
|
+
if (c !== null && typeof c === 'object') {
|
|
202
|
+
const text = typeof c.text === 'string' ? c.text : null;
|
|
203
|
+
if (text !== null) parts.push(text);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return parts.length > 0 ? parts.join('') : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Extract the opportunity id from the runner's task id form
|
|
210
|
+
* `${armId}#${opportunityId}#turn${n}` (opportunity ids may contain ':'). */
|
|
211
|
+
export function opportunityIdFromTaskId(taskId) {
|
|
212
|
+
if (typeof taskId !== 'string') return null;
|
|
213
|
+
const parts = taskId.split('#');
|
|
214
|
+
return parts.length >= 2 && parts[1].length > 0 ? parts[1] : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Rewrite pass over a TypeBox/JSON schema toward the OpenAI Structured
|
|
219
|
+
* Outputs subset. Official rules (developers.openai.com structured-outputs,
|
|
220
|
+
* supported-schemas): ALL fields must be listed in `required` (optionality
|
|
221
|
+
* is emulated with null-unions), every object sets
|
|
222
|
+
* `additionalProperties: false`, and the supported type set is
|
|
223
|
+
* String/Number/Boolean/Integer/Object/Array/Enum/anyOf — `const` is NOT
|
|
224
|
+
* supported, so `{const: X, type: "string"}` branches are rewritten to the
|
|
225
|
+
* equivalent `{type: "string", enum: [X]}`. `$schema`/`$id` are dropped.
|
|
226
|
+
*
|
|
227
|
+
* This pass alone does NOT widen `required` — use
|
|
228
|
+
* `normalizeSchemaForOpenAiStrictFull` for the real host rounds, which adds
|
|
229
|
+
* the strict-optionality pass (all properties required, originally-optional
|
|
230
|
+
* ones nullable) and pairs with `stripNullFields` to restore the original
|
|
231
|
+
* omitted-semantics before validateActorAction. Pure; never mutates input.
|
|
232
|
+
*/
|
|
233
|
+
export function normalizeSchemaForOpenAiStrict(schema) {
|
|
234
|
+
if (Array.isArray(schema)) {
|
|
235
|
+
return schema.map((item) => normalizeSchemaForOpenAiStrict(item));
|
|
236
|
+
}
|
|
237
|
+
if (schema === null || typeof schema !== 'object') {
|
|
238
|
+
return schema;
|
|
239
|
+
}
|
|
240
|
+
const out = {};
|
|
241
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
242
|
+
if (key === 'properties' && value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
243
|
+
out.properties = Object.fromEntries(
|
|
244
|
+
Object.entries(value).map(([name, sub]) => [name, normalizeSchemaForOpenAiStrict(sub)]),
|
|
245
|
+
);
|
|
246
|
+
} else if (['anyOf', 'oneOf'].includes(key) && Array.isArray(value)) {
|
|
247
|
+
out[key] = value.map((sub) => normalizeSchemaForOpenAiStrict(sub));
|
|
248
|
+
} else if (key === '$schema' || key === '$id') {
|
|
249
|
+
continue; // irrelevant (and sometimes unwelcome) to the API
|
|
250
|
+
} else {
|
|
251
|
+
out[key] = value;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// const is outside the supported keyword set; a single-value enum is its
|
|
255
|
+
// exact equivalent and IS supported.
|
|
256
|
+
if (out.const !== undefined) {
|
|
257
|
+
out.enum = [out.const];
|
|
258
|
+
delete out.const;
|
|
259
|
+
}
|
|
260
|
+
if (out.type === 'object') {
|
|
261
|
+
if (out.required === undefined) {
|
|
262
|
+
out.required = [];
|
|
263
|
+
}
|
|
264
|
+
if (out.additionalProperties === undefined) {
|
|
265
|
+
out.additionalProperties = false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Make one property schema nullable, in the form the official docs use:
|
|
272
|
+
* enums gain `null` in the value list, string types become
|
|
273
|
+
* `["<type>", "null"]`, type arrays gain "null", complex nodes gain an
|
|
274
|
+
* anyOf `{type: "null"}` branch (the docs' linked-list pattern). */
|
|
275
|
+
function makeNullable(nodeSchema) {
|
|
276
|
+
const s = nodeSchema;
|
|
277
|
+
if (s === null || typeof s !== 'object' || Array.isArray(s)) return s;
|
|
278
|
+
if (Array.isArray(s.enum) && !s.enum.includes(null)) {
|
|
279
|
+
return { ...s, enum: [...s.enum, null] };
|
|
280
|
+
}
|
|
281
|
+
if (typeof s.type === 'string') {
|
|
282
|
+
return { ...s, type: [s.type, 'null'] };
|
|
283
|
+
}
|
|
284
|
+
if (Array.isArray(s.type) && !s.type.includes('null')) {
|
|
285
|
+
return { ...s, type: [...s.type, 'null'] };
|
|
286
|
+
}
|
|
287
|
+
if (Array.isArray(s.anyOf)) {
|
|
288
|
+
return { ...s, anyOf: [...s.anyOf, { type: 'null' }] };
|
|
289
|
+
}
|
|
290
|
+
return { anyOf: [{ ...s }, { type: 'null' }] };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Full normalization used by REAL host rounds: the rewrite pass above plus
|
|
295
|
+
* the strict-optionality pass — every object's `required` lists ALL of its
|
|
296
|
+
* properties, and properties that were NOT required in the original schema
|
|
297
|
+
* become nullable (strict mode's only way to express optionality).
|
|
298
|
+
*
|
|
299
|
+
* The ACTION CONTRACT IS UNCHANGED on the wire round-trip: optional fields
|
|
300
|
+
* arrive null on the wire and `stripNullFields` deletes them again before
|
|
301
|
+
* validateActorAction, restoring the exact omitted-semantics. Pure;
|
|
302
|
+
* idempotent; never mutates the input.
|
|
303
|
+
*/
|
|
304
|
+
export function normalizeSchemaForOpenAiStrictFull(schema) {
|
|
305
|
+
const rewritten = normalizeSchemaForOpenAiStrict(schema);
|
|
306
|
+
const walk = (node) => {
|
|
307
|
+
if (Array.isArray(node)) return node.map(walk);
|
|
308
|
+
if (node === null || typeof node !== 'object') return node;
|
|
309
|
+
const out = {};
|
|
310
|
+
for (const [key, value] of Object.entries(node)) {
|
|
311
|
+
if (key === 'properties' && value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
312
|
+
const originalRequired = Array.isArray(node.required) ? node.required : [];
|
|
313
|
+
out.properties = Object.fromEntries(
|
|
314
|
+
Object.entries(value).map(([name, sub]) => [
|
|
315
|
+
name,
|
|
316
|
+
originalRequired.includes(name) ? walk(sub) : makeNullable(walk(sub)),
|
|
317
|
+
]),
|
|
318
|
+
);
|
|
319
|
+
} else if (['anyOf', 'oneOf'].includes(key) && Array.isArray(value)) {
|
|
320
|
+
out[key] = value.map(walk);
|
|
321
|
+
} else {
|
|
322
|
+
out[key] = value;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (out.type === 'object' && out.properties !== undefined) {
|
|
326
|
+
out.required = Object.keys(out.properties);
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
};
|
|
330
|
+
return walk(rewritten);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Restore the original omitted-semantics on a model reply BEFORE the lab's
|
|
335
|
+
* action validation: strict schemas force optional fields to be present
|
|
336
|
+
* (as null) on the wire; the action contract expresses optionality by
|
|
337
|
+
* ABSENCE. Recursively deletes properties whose value is exactly null.
|
|
338
|
+
* Non-object values pass through untouched.
|
|
339
|
+
*/
|
|
340
|
+
export function stripNullFields(value) {
|
|
341
|
+
if (Array.isArray(value)) {
|
|
342
|
+
return value.map((item) => stripNullFields(item));
|
|
343
|
+
}
|
|
344
|
+
if (value === null || typeof value !== 'object') {
|
|
345
|
+
return value;
|
|
346
|
+
}
|
|
347
|
+
const out = {};
|
|
348
|
+
for (const [key, v] of Object.entries(value)) {
|
|
349
|
+
if (v === null) continue; // optional field the model null-filled → omitted
|
|
350
|
+
out[key] = stripNullFields(v);
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Read-back contract for host rollout evidence (read-only, id-exact).
|
|
357
|
+
*
|
|
358
|
+
* `codex exec --json` stdout carries NO identity/model event — the model
|
|
359
|
+
* identity lives only in the rollout file the host itself writes under
|
|
360
|
+
* `$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`. This reader is
|
|
361
|
+
* the audited bridge, under a strict contract that makes cross-session
|
|
362
|
+
* confusion impossible:
|
|
363
|
+
*
|
|
364
|
+
* - the file is matched by its session_meta.session_id EQUALING the
|
|
365
|
+
* session id that came from OUR OWN spawn's event stream — never by
|
|
366
|
+
* timestamp proximity, cwd similarity or "most recent file";
|
|
367
|
+
* - at most one file may match; zero or ambiguous matches are reported as
|
|
368
|
+
* `found: false` / `ambiguous: true` and NEVER guessed;
|
|
369
|
+
* - the reader extracts only identity (thread_settings_applied.model),
|
|
370
|
+
* usage (token_usage_record) and the terminal error (task_complete
|
|
371
|
+
* error payload) — it NEVER fabricates actions or marks a run verified.
|
|
372
|
+
*
|
|
373
|
+
* Diagnostic/audit use; the formal identity status in hostSnapshot keeps
|
|
374
|
+
* deriving from whatever evidence channels were actually wired (stdout
|
|
375
|
+
* events and/or this read-back when enabled per round).
|
|
376
|
+
*/
|
|
377
|
+
export function readRolloutEvidenceForSession({ codexHome, sessionId, now = new Date() } = {}) {
|
|
378
|
+
if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
|
|
379
|
+
throw new Error('readRolloutEvidenceForSession: sessionId is required');
|
|
380
|
+
}
|
|
381
|
+
const home = codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
|
|
382
|
+
const sessionsRoot = path.join(home, 'sessions');
|
|
383
|
+
// search today and yesterday (UTC) — a round can straddle midnight
|
|
384
|
+
const days = [0, -1].map((offset) => {
|
|
385
|
+
const d = new Date(now.getTime() + offset * 86_400_000);
|
|
386
|
+
return [String(d.getUTCFullYear()), String(d.getUTCMonth() + 1).padStart(2, '0'), String(d.getUTCDate()).padStart(2, '0')];
|
|
387
|
+
});
|
|
388
|
+
const candidates = [];
|
|
389
|
+
for (const [y, m, d] of days) {
|
|
390
|
+
const dir = path.join(sessionsRoot, y, m, d);
|
|
391
|
+
let entries;
|
|
392
|
+
try {
|
|
393
|
+
entries = fs.readdirSync(dir);
|
|
394
|
+
} catch {
|
|
395
|
+
continue; // day directory absent
|
|
396
|
+
}
|
|
397
|
+
for (const name of entries) {
|
|
398
|
+
if (name.startsWith('rollout-') && name.endsWith('.jsonl')) candidates.push(path.join(dir, name));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const matches = [];
|
|
402
|
+
for (const file of candidates) {
|
|
403
|
+
let content;
|
|
404
|
+
try {
|
|
405
|
+
content = fs.readFileSync(file, 'utf8');
|
|
406
|
+
} catch {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
// the session id must appear as OUR session's own metadata; a cheap
|
|
410
|
+
// pre-filter before JSON parsing (the id is unique enough for that)
|
|
411
|
+
if (!content.includes(sessionId)) continue;
|
|
412
|
+
const evidence = { file, sessionId: null, model: null, modelProviderId: null, usage: null, terminalError: null, settingsSeen: false };
|
|
413
|
+
for (const line of content.split('\n')) {
|
|
414
|
+
const trimmed = line.trim();
|
|
415
|
+
if (trimmed.length === 0) continue;
|
|
416
|
+
let ev;
|
|
417
|
+
try {
|
|
418
|
+
ev = JSON.parse(trimmed);
|
|
419
|
+
} catch {
|
|
420
|
+
continue; // torn/foreign line — not ours to judge
|
|
421
|
+
}
|
|
422
|
+
const payload = ev?.payload !== undefined ? ev.payload : ev;
|
|
423
|
+
if (ev?.type === 'session_meta' || payload?.session_id) {
|
|
424
|
+
if (typeof payload.session_id === 'string' && evidence.sessionId === null) evidence.sessionId = payload.session_id;
|
|
425
|
+
}
|
|
426
|
+
if (payload?.type === 'thread_settings_applied' && payload.thread_settings) {
|
|
427
|
+
evidence.settingsSeen = true;
|
|
428
|
+
evidence.model = payload.thread_settings.model ?? evidence.model;
|
|
429
|
+
evidence.modelProviderId = payload.thread_settings.model_provider_id ?? evidence.modelProviderId;
|
|
430
|
+
}
|
|
431
|
+
if (ev?.type === 'token_usage_record') {
|
|
432
|
+
// last record wins; fields mirror what the stream would carry
|
|
433
|
+
evidence.usage = {
|
|
434
|
+
inputTokens: payload.input_tokens ?? payload.usage?.input_tokens ?? null,
|
|
435
|
+
cachedInputTokens: payload.cached_input_tokens ?? payload.usage?.cached_input_tokens ?? null,
|
|
436
|
+
cacheWriteInputTokens: payload.cache_write_input_tokens ?? payload.usage?.cache_write_input_tokens ?? null,
|
|
437
|
+
outputTokens: payload.output_tokens ?? payload.usage?.output_tokens ?? null,
|
|
438
|
+
reasoningOutputTokens: payload.reasoning_output_tokens ?? payload.usage?.reasoning_output_tokens ?? null,
|
|
439
|
+
totalTokens: payload.total_tokens ?? payload.usage?.total_tokens ?? null,
|
|
440
|
+
usageSource: 'rollout_token_usage_record',
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (payload?.type === 'task_complete' && payload.error) {
|
|
444
|
+
evidence.terminalError = String(payload.error?.message ?? JSON.stringify(payload.error)).slice(0, 400);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (evidence.sessionId === sessionId) matches.push(evidence);
|
|
448
|
+
}
|
|
449
|
+
if (matches.length === 0) {
|
|
450
|
+
return { found: false, ambiguous: false, reason: `no rollout file under ${sessionsRoot} carries session_meta.session_id === ${sessionId}` };
|
|
451
|
+
}
|
|
452
|
+
if (matches.length > 1) {
|
|
453
|
+
return { found: false, ambiguous: true, reason: `${matches.length} rollout files claim session ${sessionId} — refusing to guess`, files: matches.map((m) => m.file) };
|
|
454
|
+
}
|
|
455
|
+
return { found: true, ambiguous: false, ...matches[0] };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Create the Codex host transport. One instance serves ONE arm: rounds are
|
|
460
|
+
* delivered per call; a change of opportunityId in request.taskId starts a
|
|
461
|
+
* fresh host session (opportunity isolation). Implements the same
|
|
462
|
+
* `complete(request)` contract as every other lab transport.
|
|
463
|
+
*
|
|
464
|
+
* Fail-closed at creation: the codex binary must answer `--version` (local
|
|
465
|
+
* process probe, no model call) and every parameter must validate — a
|
|
466
|
+
* missing CLI or invalid config means no spawn can ever be attempted.
|
|
467
|
+
*/
|
|
468
|
+
export function createCodexHostTransport({
|
|
469
|
+
model,
|
|
470
|
+
codexBin = 'codex',
|
|
471
|
+
scratchRoot,
|
|
472
|
+
profile = null,
|
|
473
|
+
threadSource = 'pl-lab',
|
|
474
|
+
timeoutMsPerCall = 180_000,
|
|
475
|
+
outputSchemaFile = null,
|
|
476
|
+
outputLastMessageFile = null,
|
|
477
|
+
codexHome = null,
|
|
478
|
+
rolloutReadback = true,
|
|
479
|
+
extraArgs = [],
|
|
480
|
+
opportunityCaps = {},
|
|
481
|
+
hostLedger = null,
|
|
482
|
+
spawnProcess = spawn,
|
|
483
|
+
versionProbe = null,
|
|
484
|
+
readRollout = readRolloutEvidenceForSession,
|
|
485
|
+
} = {}) {
|
|
486
|
+
if (typeof model !== 'string' || model.trim().length === 0) {
|
|
487
|
+
throw new Error('createCodexHostTransport: model (requested model id, e.g. gpt-6-luna) is required');
|
|
488
|
+
}
|
|
489
|
+
if (typeof scratchRoot !== 'string' || scratchRoot.trim().length === 0) {
|
|
490
|
+
throw new Error('createCodexHostTransport: scratchRoot (per-arm spawn cwd) is required');
|
|
491
|
+
}
|
|
492
|
+
if (!Number.isFinite(timeoutMsPerCall) || timeoutMsPerCall <= 0) {
|
|
493
|
+
throw new Error('createCodexHostTransport: timeoutMsPerCall must be a positive finite number');
|
|
494
|
+
}
|
|
495
|
+
if (hostLedger !== null && typeof hostLedger.reserveExec !== 'function') {
|
|
496
|
+
throw new Error('createCodexHostTransport: hostLedger must be a host process ledger');
|
|
497
|
+
}
|
|
498
|
+
if (outputSchemaFile !== null && (typeof outputSchemaFile !== 'string' || outputSchemaFile.trim().length === 0)) {
|
|
499
|
+
throw new Error('createCodexHostTransport: outputSchemaFile must be a non-empty path or null');
|
|
500
|
+
}
|
|
501
|
+
if (outputLastMessageFile !== null && (typeof outputLastMessageFile !== 'string' || outputLastMessageFile.trim().length === 0)) {
|
|
502
|
+
throw new Error('createCodexHostTransport: outputLastMessageFile must be a non-empty path or null');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Fail-closed local probe: no codex binary → refuse transport creation
|
|
506
|
+
// before any round could ever be attempted. (--version is a local process,
|
|
507
|
+
// not a model call. The probe result doubles as the recorded cliVersion.)
|
|
508
|
+
const probe = versionProbe
|
|
509
|
+
? versionProbe(codexBin)
|
|
510
|
+
: (() => {
|
|
511
|
+
try {
|
|
512
|
+
return spawnSync(codexBin, ['--version'], { encoding: 'utf8', timeout: 15_000 });
|
|
513
|
+
} catch (err) {
|
|
514
|
+
return { error: err, status: null };
|
|
515
|
+
}
|
|
516
|
+
})();
|
|
517
|
+
const probedVersion = typeof probe?.stdout === 'string' && probe.stdout.trim().length > 0 ? probe.stdout.trim().split('\n')[0] : null;
|
|
518
|
+
if (probe?.error || (typeof probe?.status === 'number' && probe.status !== 0)) {
|
|
519
|
+
throw new Error(`createCodexHostTransport: codex CLI probe failed (${codexBin} --version): ${probe?.error?.message ?? `exit ${probe?.status}`}. No host rounds can run without the owner's Codex CLI (fail-closed).`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const identity = {
|
|
523
|
+
requestedModel: model,
|
|
524
|
+
observedModel: null,
|
|
525
|
+
observedModelProviderId: null,
|
|
526
|
+
evidenceSource: null,
|
|
527
|
+
cliVersion: probedVersion,
|
|
528
|
+
status: 'MODEL_IDENTITY_UNVERIFIED', // until the HOST reports its own model
|
|
529
|
+
};
|
|
530
|
+
let currentOpportunityId = null;
|
|
531
|
+
let sessionId = null;
|
|
532
|
+
let roundSeq = 0;
|
|
533
|
+
let malformedLines = 0;
|
|
534
|
+
let toolCallsObserved = 0;
|
|
535
|
+
let rounds = 0;
|
|
536
|
+
/** Per-opportunity child ledgers (token feedback aggregation). */
|
|
537
|
+
const oppLedgers = new Map();
|
|
538
|
+
/** Completed round records, traceable to session + turn. */
|
|
539
|
+
const roundRecords = [];
|
|
540
|
+
/** session records: one per host session actually started. */
|
|
541
|
+
const sessionRecords = [];
|
|
542
|
+
|
|
543
|
+
const oppLedgerFor = (opportunityId) => {
|
|
544
|
+
if (hostLedger === null) return null;
|
|
545
|
+
if (!oppLedgers.has(opportunityId)) {
|
|
546
|
+
oppLedgers.set(opportunityId, createHostProcessLedger({
|
|
547
|
+
scope: `opp:${opportunityId}`,
|
|
548
|
+
caps: opportunityCaps,
|
|
549
|
+
parent: hostLedger,
|
|
550
|
+
}));
|
|
551
|
+
}
|
|
552
|
+
return oppLedgers.get(opportunityId);
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
function buildArgs(forResume) {
|
|
556
|
+
// `codex exec resume <SESSION_ID> [OPTIONS] [PROMPT]` — resume (and its
|
|
557
|
+
// session id) sits immediately after the subcommand; shared options and
|
|
558
|
+
// the prompt follow. First rounds use `codex exec [OPTIONS] [PROMPT]`.
|
|
559
|
+
const rest = [
|
|
560
|
+
'--json', '--skip-git-repo-check', '-C', scratchRoot, '-s', 'read-only', '--thread-source', threadSource, '-m', model,
|
|
561
|
+
];
|
|
562
|
+
if (profile !== null) rest.push('-p', profile);
|
|
563
|
+
if (outputSchemaFile !== null) rest.push('--output-schema', outputSchemaFile);
|
|
564
|
+
if (outputLastMessageFile !== null) rest.push('--output-last-message', outputLastMessageFile);
|
|
565
|
+
rest.push(...extraArgs);
|
|
566
|
+
return forResume ? ['exec', 'resume', sessionId, ...rest] : ['exec', ...rest];
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Collect ONE child's stdout until close; returns parsed events + raw info. */
|
|
570
|
+
function runChild(args, promptText) {
|
|
571
|
+
return new Promise((resolve, reject) => {
|
|
572
|
+
const events = [];
|
|
573
|
+
let stdoutBuf = '';
|
|
574
|
+
let stderrText = '';
|
|
575
|
+
let child;
|
|
576
|
+
let settled = false;
|
|
577
|
+
let timedOut = false;
|
|
578
|
+
const startedAt = Date.now();
|
|
579
|
+
// PROMPT VIA STDIN (rc): `codex exec -` reads the instructions from
|
|
580
|
+
// stdin. Windows CreateProcess quoting is unreliable for multi-line
|
|
581
|
+
// argv values (a multi-line prompt arg reaches codex truncated, which
|
|
582
|
+
// then waits on stdin and exits 1) — the pipe is the exact, tested
|
|
583
|
+
// channel. `-` marks the prompt position explicitly on both exec and
|
|
584
|
+
// `exec resume`.
|
|
585
|
+
const childArgs = [...args, '-'];
|
|
586
|
+
try {
|
|
587
|
+
child = spawnProcess(codexBin, childArgs, { cwd: scratchRoot, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
588
|
+
} catch (err) {
|
|
589
|
+
reject(Object.assign(new Error(`codex spawn failed: ${err?.message ?? String(err)}`), { code: 'spawn_failed', wallMs: 0 }));
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
child.stdin?.on('error', () => { /* EPIPE if codex exits early — the close handler reports the real failure */ });
|
|
593
|
+
child.stdin?.write(promptText, 'utf8');
|
|
594
|
+
child.stdin?.end();
|
|
595
|
+
const timer = setTimeout(() => {
|
|
596
|
+
timedOut = true;
|
|
597
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
598
|
+
}, timeoutMsPerCall);
|
|
599
|
+
child.stdout?.on('data', (chunk) => {
|
|
600
|
+
stdoutBuf += chunk.toString('utf8');
|
|
601
|
+
let idx;
|
|
602
|
+
while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
|
|
603
|
+
const line = stdoutBuf.slice(0, idx).trim();
|
|
604
|
+
stdoutBuf = stdoutBuf.slice(idx + 1);
|
|
605
|
+
if (line.length === 0) continue; // empty lines are legal
|
|
606
|
+
try {
|
|
607
|
+
events.push(JSON.parse(line));
|
|
608
|
+
} catch {
|
|
609
|
+
malformedLines += 1; // torn/partial or non-JSON line — counted, never parsed-as-truth
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
child.stderr?.on('data', (chunk) => {
|
|
614
|
+
stderrText += chunk.toString('utf8');
|
|
615
|
+
});
|
|
616
|
+
const finish = (err) => {
|
|
617
|
+
if (settled) return;
|
|
618
|
+
settled = true;
|
|
619
|
+
clearTimeout(timer);
|
|
620
|
+
if (err) reject(err);
|
|
621
|
+
else resolve({ events, exitCode: child.exitCode, wallMs: Date.now() - startedAt, stderrText, timedOut });
|
|
622
|
+
};
|
|
623
|
+
const failWith = (code, message) => {
|
|
624
|
+
const err = Object.assign(new Error(message), { code });
|
|
625
|
+
err.wallMs = Date.now() - startedAt;
|
|
626
|
+
err.stderrTail = stderrText.slice(0, 300);
|
|
627
|
+
finish(err);
|
|
628
|
+
};
|
|
629
|
+
child.on('error', (err) => failWith('spawn_failed', `codex process error: ${err?.message ?? String(err)}`));
|
|
630
|
+
child.on('close', (code) => {
|
|
631
|
+
if (timedOut) {
|
|
632
|
+
failWith('timeout', `codex exec timed out after ${timeoutMsPerCall}ms and was killed (fail-loud, no partial round is graded as success)`);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (code !== 0) {
|
|
636
|
+
failWith('nonzero_exit', `codex exec exited ${code}: ${stderrText.slice(0, 300)}`);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
finish(null);
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Consume ONE round's event stream. TWO real wire formats exist and BOTH
|
|
645
|
+
* must be understood (the 2026-10-23 smoke failed on exactly this):
|
|
646
|
+
*
|
|
647
|
+
* 1. `codex exec --json` STDOUT format (official, documented): top-level
|
|
648
|
+
* snake_case events — {"type":"thread.started","thread_id":…},
|
|
649
|
+
* {"type":"turn.completed","usage":{input_tokens,…}},
|
|
650
|
+
* {"type":"item.completed","item":{"type":"agent_message","text":…}},
|
|
651
|
+
* {"type":"error","message":…}. No identity/model event exists here.
|
|
652
|
+
* 2. ROLLOUT internal format (what lands in ~/.codex/sessions):
|
|
653
|
+
* {"type":"event_msg","payload":{type:"thread_settings_applied",…}},
|
|
654
|
+
* item payloads use CamelCase (AgentMessage/custom_tool_call).
|
|
655
|
+
*
|
|
656
|
+
* Returns the final agent text, per-round usage, thread ids, any error
|
|
657
|
+
* event text, and whether the stream carried any recognized event at all
|
|
658
|
+
* (an absent stream is an auditable fact, never silently accepted). */
|
|
659
|
+
function consumeEvents(events) {
|
|
660
|
+
let lastAgentText = null;
|
|
661
|
+
let errorEventText = null;
|
|
662
|
+
let recognizedEvents = 0;
|
|
663
|
+
// last-wins per turn: cumulative figures (token_count.info.total_token_usage,
|
|
664
|
+
// session-level totals) must never be summed per round — the round delta
|
|
665
|
+
// lives in last_token_usage / turn.completed.usage.
|
|
666
|
+
const usageByTurn = new Map();
|
|
667
|
+
const threadIds = [];
|
|
668
|
+
for (const ev of events) {
|
|
669
|
+
const payload = ev !== null && typeof ev === 'object' && ev.payload !== undefined ? ev.payload : ev;
|
|
670
|
+
if (payload === null || typeof payload !== 'object') continue;
|
|
671
|
+
const type = payload.type;
|
|
672
|
+
// ── stdout official format (top-level snake_case) ──
|
|
673
|
+
if (type === 'thread.started' || type === 'session_configured') {
|
|
674
|
+
recognizedEvents += 1;
|
|
675
|
+
if (typeof payload.thread_id === 'string') threadIds.push(payload.thread_id);
|
|
676
|
+
if (typeof payload.session_id === 'string') threadIds.push(payload.session_id);
|
|
677
|
+
} else if (type === 'turn.completed' || type === 'turn.failed') {
|
|
678
|
+
recognizedEvents += 1;
|
|
679
|
+
if (typeof payload.thread_id === 'string') threadIds.push(payload.thread_id);
|
|
680
|
+
const turnKey = payload.turn_id ?? `turn.${type}`;
|
|
681
|
+
usageByTurn.set(turnKey, payload.usage ?? null);
|
|
682
|
+
if (type === 'turn.failed' && payload.error) {
|
|
683
|
+
errorEventText = String(payload.error?.message ?? JSON.stringify(payload.error)).slice(0, 400);
|
|
684
|
+
}
|
|
685
|
+
} else if (type === 'item.completed' || type === 'item.updated' || type === 'item.started') {
|
|
686
|
+
recognizedEvents += 1;
|
|
687
|
+
if (typeof payload.thread_id === 'string') threadIds.push(payload.thread_id);
|
|
688
|
+
const item = payload.item;
|
|
689
|
+
if (item !== null && typeof item === 'object') {
|
|
690
|
+
const itemType = String(item.type ?? item.item_type ?? '');
|
|
691
|
+
if (itemType === 'agent_message' || itemType === 'AgentMessage') {
|
|
692
|
+
const text = itemType === 'agent_message'
|
|
693
|
+
? (typeof item.text === 'string' && item.text.length > 0 ? item.text : null)
|
|
694
|
+
: textFromAgentMessage(item);
|
|
695
|
+
if (text !== null) lastAgentText = text; // last agent message wins
|
|
696
|
+
} else if (/tool|command|search|file_change|exec/i.test(itemType)) {
|
|
697
|
+
toolCallsObserved += 1; // protocol deviation signal — recorded, never silent
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
} else if (type === 'error') {
|
|
701
|
+
recognizedEvents += 1;
|
|
702
|
+
errorEventText = String(payload.message ?? JSON.stringify(payload)).slice(0, 400);
|
|
703
|
+
if (typeof payload.thread_id === 'string') threadIds.push(payload.thread_id);
|
|
704
|
+
} else if (type === 'thread_settings_applied') {
|
|
705
|
+
// ── rollout format (also the ONLY identity source) ──
|
|
706
|
+
recognizedEvents += 1;
|
|
707
|
+
threadIds.push(payload.thread_id);
|
|
708
|
+
const settings = payload.thread_settings;
|
|
709
|
+
if (settings !== null && typeof settings === 'object') {
|
|
710
|
+
if (typeof settings.model === 'string' && settings.model.length > 0) identity.observedModel = settings.model;
|
|
711
|
+
if (typeof settings.model_provider_id === 'string') identity.observedModelProviderId = settings.model_provider_id;
|
|
712
|
+
}
|
|
713
|
+
} else if (type === 'item_completed') {
|
|
714
|
+
recognizedEvents += 1;
|
|
715
|
+
if (typeof payload.thread_id === 'string') threadIds.push(payload.thread_id);
|
|
716
|
+
if (payload.item !== null && typeof payload.item === 'object') {
|
|
717
|
+
if (payload.item.type === 'custom_tool_call' || payload.item.type === 'local_custom_tool_call' || payload.item.type === 'function_call') {
|
|
718
|
+
toolCallsObserved += 1; // protocol deviation signal — recorded, never silent
|
|
719
|
+
}
|
|
720
|
+
const text = textFromAgentMessage(payload.item);
|
|
721
|
+
if (text !== null) lastAgentText = text; // last AgentMessage wins
|
|
722
|
+
}
|
|
723
|
+
} else if (type === 'token_count') {
|
|
724
|
+
recognizedEvents += 1;
|
|
725
|
+
if (typeof payload.thread_id === 'string') threadIds.push(payload.thread_id);
|
|
726
|
+
const info = payload.info;
|
|
727
|
+
const last = info !== null && typeof info === 'object' ? info.last_token_usage ?? null : null;
|
|
728
|
+
if (payload.turn_id !== undefined && payload.turn_id !== null) {
|
|
729
|
+
usageByTurn.set(payload.turn_id, last);
|
|
730
|
+
} else {
|
|
731
|
+
usageByTurn.set('(no-turn-id)', last);
|
|
732
|
+
}
|
|
733
|
+
} else if (type === 'task_started' || type === 'task_complete') {
|
|
734
|
+
recognizedEvents += 1;
|
|
735
|
+
if (typeof payload.thread_id === 'string') threadIds.push(payload.thread_id);
|
|
736
|
+
if (typeof payload.turn_id === 'string' && type === 'task_complete') {
|
|
737
|
+
if (!usageByTurn.has(payload.turn_id)) usageByTurn.set(payload.turn_id, null);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// Round usage = the LAST turn seen in this exec call (one exec == one
|
|
742
|
+
// agent round in the lab protocol). Missing token_count → unavailable.
|
|
743
|
+
let roundUsage = { ...EMPTY_TOKENS, usageSource: 'unavailable' };
|
|
744
|
+
if (usageByTurn.size > 0) {
|
|
745
|
+
const [turnId, last] = [...usageByTurn.entries()][usageByTurn.size - 1];
|
|
746
|
+
roundUsage = { ...normalizeUsage(last), turnId };
|
|
747
|
+
}
|
|
748
|
+
return { lastAgentText, roundUsage, threadIds: [...new Set(threadIds)], errorEventText, recognizedEvents };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
kind: 'codex-host',
|
|
753
|
+
/** Same contract as every lab transport: one agent round in, one action
|
|
754
|
+
* payload out. Throws labeled errors; never fakes a payload. */
|
|
755
|
+
async complete(request) {
|
|
756
|
+
const opportunityId = opportunityIdFromTaskId(request.taskId);
|
|
757
|
+
if (opportunityId === null) {
|
|
758
|
+
throw Object.assign(new Error(`codex-host transport: request.taskId '${String(request.taskId)}' does not carry an opportunity segment — opportunity isolation cannot be proven`), { code: 'invalid_request' });
|
|
759
|
+
}
|
|
760
|
+
const startingNewSession = opportunityId !== currentOpportunityId || sessionId === null;
|
|
761
|
+
if (startingNewSession) {
|
|
762
|
+
// finalize the previous opportunity's child bookkeeping
|
|
763
|
+
if (currentOpportunityId !== null) {
|
|
764
|
+
const prev = oppLedgers.get(currentOpportunityId);
|
|
765
|
+
hostLedger?.clearActiveChild();
|
|
766
|
+
prev?.clearActiveChild();
|
|
767
|
+
}
|
|
768
|
+
sessionId = null;
|
|
769
|
+
currentOpportunityId = opportunityId;
|
|
770
|
+
}
|
|
771
|
+
const oppLedger = oppLedgerFor(opportunityId);
|
|
772
|
+
const isNewSession = sessionId === null;
|
|
773
|
+
// Pre-spawn reservation across opportunity → arm → study. Exec count
|
|
774
|
+
// and session count are HARD limits; observed token/wall totals gate
|
|
775
|
+
// the NEXT round (recorded feedback, never a claim about the current).
|
|
776
|
+
hostLedger?.setActiveChild(oppLedger);
|
|
777
|
+
try {
|
|
778
|
+
(oppLedger ?? hostLedger)?.reserveExec({ isNewSession, reason: request.taskId ?? 'host round' });
|
|
779
|
+
} catch (err) {
|
|
780
|
+
hostLedger?.clearActiveChild();
|
|
781
|
+
throw err;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const promptText = `${request.systemPrompt}\n\n${request.userPrompt}`;
|
|
785
|
+
// Old-file poisoning guard for --output-last-message: the file must be
|
|
786
|
+
// produced by THIS round. Delete any pre-existing copy before the
|
|
787
|
+
// spawn; a file that exists after the child exits was written by it.
|
|
788
|
+
if (outputLastMessageFile !== null && fs.existsSync(outputLastMessageFile)) {
|
|
789
|
+
fs.rmSync(outputLastMessageFile, { force: true });
|
|
790
|
+
}
|
|
791
|
+
// first round of a session = plain exec; any later round = resume
|
|
792
|
+
const args = buildArgs(!isNewSession);
|
|
793
|
+
roundSeq += 1;
|
|
794
|
+
const roundNo = roundSeq;
|
|
795
|
+
let result;
|
|
796
|
+
try {
|
|
797
|
+
result = await runChild(args, promptText);
|
|
798
|
+
} catch (err) {
|
|
799
|
+
hostLedger?.clearActiveChild();
|
|
800
|
+
// A spawn attempt consumed a process slot (OS-level spawn failures
|
|
801
|
+
// excepted). Wall-clock of a timed-out/killed child stays on the
|
|
802
|
+
// record — it ran on the host even though it produced no round.
|
|
803
|
+
if (err?.code !== 'spawn_failed') {
|
|
804
|
+
(oppLedger ?? hostLedger)?.recordUsage({ tokensTotal: null, wallMs: err?.wallMs ?? 0 });
|
|
805
|
+
}
|
|
806
|
+
roundRecords.push({ roundNo, opportunityId, sessionId, error: err?.message ?? String(err), usage: null, stderrText: err?.stderrTail ?? null });
|
|
807
|
+
throw err;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const { lastAgentText: streamAgentText, roundUsage: streamUsage, threadIds, errorEventText: errorEventTextFromStream, recognizedEvents } = consumeEvents(result.events);
|
|
811
|
+
// --output-last-message (official interface): when present and non-empty
|
|
812
|
+
// it is the AUTHORITATIVE final message (the file was deleted before
|
|
813
|
+
// the spawn, so its existence proves this round produced it). The
|
|
814
|
+
// stream text is the fallback channel.
|
|
815
|
+
let lastAgentText = streamAgentText;
|
|
816
|
+
let finalMessageSource = 'stdout-stream';
|
|
817
|
+
if (outputLastMessageFile !== null) {
|
|
818
|
+
try {
|
|
819
|
+
const fileText = fs.readFileSync(outputLastMessageFile, 'utf8');
|
|
820
|
+
if (fileText.trim().length > 0) {
|
|
821
|
+
lastAgentText = fileText;
|
|
822
|
+
finalMessageSource = 'output-last-message-file';
|
|
823
|
+
}
|
|
824
|
+
} catch { /* absent/empty → stream channel (or the explicit failure below) */ }
|
|
825
|
+
finally {
|
|
826
|
+
try { fs.rmSync(outputLastMessageFile, { force: true }); } catch { /* best-effort cleanup */ }
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// errorEventText is MUTABLE after this point: the rollout read-back may
|
|
830
|
+
// surface the terminal error when the stdout stream carried none.
|
|
831
|
+
let errorEventText = errorEventTextFromStream;
|
|
832
|
+
if (isNewSession) {
|
|
833
|
+
const resolved = threadIds[0] ?? null;
|
|
834
|
+
if (resolved === null) {
|
|
835
|
+
hostLedger?.clearActiveChild();
|
|
836
|
+
(oppLedger ?? hostLedger)?.recordUsage({ tokensTotal: null, wallMs: result.wallMs });
|
|
837
|
+
roundRecords.push({
|
|
838
|
+
roundNo,
|
|
839
|
+
opportunityId,
|
|
840
|
+
sessionId: null,
|
|
841
|
+
error: 'no session/thread id in host event stream',
|
|
842
|
+
usage: streamUsage,
|
|
843
|
+
stderrText: result.stderrText.slice(0, 300),
|
|
844
|
+
streamAudit: { parsedEvents: result.events.length, recognizedEvents, errorEventText, exitCode: result.exitCode },
|
|
845
|
+
});
|
|
846
|
+
throw Object.assign(new Error(
|
|
847
|
+
`codex-host transport: host event stream carried no thread/session id (parsed ${result.events.length} events, ${recognizedEvents} recognized, exit ${result.exitCode}${errorEventText ? `, stream error: ${errorEventText.slice(0, 160)}` : ''}) — refusing to guess or use resume --last (shared CODEX_HOME); next rounds cannot continue this session (fail-loud). Read-only diagnosis: check the host's own rollout under CODEX_HOME/sessions for this invocation.`,
|
|
848
|
+
), { code: 'host_session_unavailable' });
|
|
849
|
+
}
|
|
850
|
+
sessionId = resolved;
|
|
851
|
+
// Identity read-back under the strict id-exact contract: the stdout
|
|
852
|
+
// stream has no model event, so the host's OWN rollout file — matched
|
|
853
|
+
// by session_meta.session_id === OUR stream-provided id — supplies
|
|
854
|
+
// the identity (and, when the stream carried no usage, the round's
|
|
855
|
+
// token total). A failed read-back keeps UNVERIFIED honestly.
|
|
856
|
+
if (identity.observedModel === null && rolloutReadback) {
|
|
857
|
+
try {
|
|
858
|
+
const evidence = readRollout({ codexHome, sessionId });
|
|
859
|
+
if (evidence.found && typeof evidence.model === 'string' && evidence.model.length > 0) {
|
|
860
|
+
identity.observedModel = evidence.model;
|
|
861
|
+
identity.observedModelProviderId = evidence.modelProviderId ?? identity.observedModelProviderId;
|
|
862
|
+
identity.evidenceSource = 'rollout-readback (session_meta id match)';
|
|
863
|
+
}
|
|
864
|
+
if (streamUsage.usageSource === 'unavailable' && evidence.found && evidence.usage) {
|
|
865
|
+
streamUsage.inputTokens = evidence.usage.inputTokens;
|
|
866
|
+
streamUsage.cachedInputTokens = evidence.usage.cachedInputTokens;
|
|
867
|
+
streamUsage.cacheWriteInputTokens = evidence.usage.cacheWriteInputTokens;
|
|
868
|
+
streamUsage.outputTokens = evidence.usage.outputTokens;
|
|
869
|
+
streamUsage.reasoningOutputTokens = evidence.usage.reasoningOutputTokens;
|
|
870
|
+
streamUsage.totalTokens = evidence.usage.totalTokens;
|
|
871
|
+
streamUsage.usageSource = evidence.usage.usageSource;
|
|
872
|
+
}
|
|
873
|
+
if (evidence.found && evidence.terminalError && !errorEventText) {
|
|
874
|
+
errorEventText = evidence.terminalError;
|
|
875
|
+
}
|
|
876
|
+
} catch { // read-back is best-effort: identity simply stays UNVERIFIED
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
sessionRecords.push({
|
|
880
|
+
sessionId,
|
|
881
|
+
opportunityId,
|
|
882
|
+
requestedModel: model,
|
|
883
|
+
observedModel: identity.observedModel,
|
|
884
|
+
identitySource: identity.evidenceSource ?? (identity.observedModel ? 'stdout-stream' : null),
|
|
885
|
+
identityStatus: identity.observedModel === null
|
|
886
|
+
? 'MODEL_IDENTITY_UNVERIFIED'
|
|
887
|
+
: identity.observedModel === model ? 'VERIFIED' : 'MODEL_IDENTITY_MISMATCH',
|
|
888
|
+
cliVersion: identity.cliVersion,
|
|
889
|
+
startedAt: new Date().toISOString(),
|
|
890
|
+
});
|
|
891
|
+
} else if (identity.observedModel === null && rolloutReadback && sessionId) {
|
|
892
|
+
// later rounds of a session where round 1 had no stream identity
|
|
893
|
+
try {
|
|
894
|
+
const evidence = readRollout({ codexHome, sessionId });
|
|
895
|
+
if (evidence.found && typeof evidence.model === 'string' && evidence.model.length > 0) {
|
|
896
|
+
identity.observedModel = evidence.model;
|
|
897
|
+
identity.observedModelProviderId = evidence.modelProviderId ?? identity.observedModelProviderId;
|
|
898
|
+
identity.evidenceSource = 'rollout-readback (session_meta id match)';
|
|
899
|
+
}
|
|
900
|
+
} catch { /* stays UNVERIFIED */ }
|
|
901
|
+
}
|
|
902
|
+
const tokensTotal = streamUsage.totalTokens;
|
|
903
|
+
(oppLedger ?? hostLedger)?.recordUsage({ tokensTotal, wallMs: result.wallMs });
|
|
904
|
+
hostLedger?.clearActiveChild();
|
|
905
|
+
rounds += 1;
|
|
906
|
+
roundRecords.push({
|
|
907
|
+
roundNo,
|
|
908
|
+
opportunityId,
|
|
909
|
+
sessionId,
|
|
910
|
+
usage: streamUsage,
|
|
911
|
+
exitCode: result.exitCode,
|
|
912
|
+
wallMs: result.wallMs,
|
|
913
|
+
stderrText: result.stderrText.slice(0, 300),
|
|
914
|
+
streamAudit: { parsedEvents: result.events.length, recognizedEvents, errorEventText, finalMessageSource },
|
|
915
|
+
});
|
|
916
|
+
if (typeof lastAgentText !== 'string' || lastAgentText.trim().length === 0) {
|
|
917
|
+
throw Object.assign(new Error(
|
|
918
|
+
`codex-host transport: no agent message on any channel (stream events: ${result.events.length} parsed/${recognizedEvents} recognized; output-last-message file: ${outputLastMessageFile !== null ? 'configured' : 'not configured'}; exit ${result.exitCode}) — refusing to fabricate an action`,
|
|
919
|
+
), { code: 'no_response' });
|
|
920
|
+
}
|
|
921
|
+
let payload;
|
|
922
|
+
try {
|
|
923
|
+
payload = JSON.parse(lastAgentText.trim());
|
|
924
|
+
} catch {
|
|
925
|
+
throw Object.assign(new Error(`codex-host transport: final agent message is not valid JSON (source: ${finalMessageSource}; first 120 chars: ${lastAgentText.trim().slice(0, 120)})`), { code: 'invalid_output' });
|
|
926
|
+
}
|
|
927
|
+
// Strict schemas force optional fields to arrive as explicit nulls on
|
|
928
|
+
// the wire; the action contract expresses optionality by absence.
|
|
929
|
+
// Restore it HERE — before the payload reaches validateActorAction.
|
|
930
|
+
payload = stripNullFields(payload);
|
|
931
|
+
return {
|
|
932
|
+
payload,
|
|
933
|
+
runId: `${sessionId}#round${roundNo}`,
|
|
934
|
+
hostRound: {
|
|
935
|
+
sessionId,
|
|
936
|
+
roundNo,
|
|
937
|
+
opportunityId,
|
|
938
|
+
usage: streamUsage,
|
|
939
|
+
exitCode: result.exitCode,
|
|
940
|
+
wallMs: result.wallMs,
|
|
941
|
+
toolCallsObserved,
|
|
942
|
+
malformedLines,
|
|
943
|
+
},
|
|
944
|
+
};
|
|
945
|
+
},
|
|
946
|
+
/** Post-run audit snapshot: identity, sessions, rounds, observed usage,
|
|
947
|
+
* process-ledger chain. This is OBSERVED feedback + hard-cap accounting,
|
|
948
|
+
* never an HTTP/USD ledger. */
|
|
949
|
+
hostSnapshot() {
|
|
950
|
+
const tokens = { ...EMPTY_TOKENS, inputTokens: 0, cachedInputTokens: 0, cacheWriteInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, roundsWithUsage: 0, roundsWithoutUsage: 0, totalsAvailable: null };
|
|
951
|
+
for (const r of roundRecords) {
|
|
952
|
+
const u = r.usage;
|
|
953
|
+
if (u !== null && u !== undefined && u.usageSource !== 'unavailable') {
|
|
954
|
+
tokens.inputTokens += u.inputTokens ?? 0;
|
|
955
|
+
tokens.cachedInputTokens += u.cachedInputTokens ?? 0;
|
|
956
|
+
tokens.cacheWriteInputTokens += u.cacheWriteInputTokens ?? 0;
|
|
957
|
+
tokens.outputTokens += u.outputTokens ?? 0;
|
|
958
|
+
tokens.reasoningOutputTokens += u.reasoningOutputTokens ?? 0;
|
|
959
|
+
tokens.totalTokens += u.totalTokens ?? 0;
|
|
960
|
+
tokens.roundsWithUsage += 1;
|
|
961
|
+
} else {
|
|
962
|
+
tokens.roundsWithoutUsage += 1;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
tokens.totalsAvailable = roundRecords.length > 0 && tokens.roundsWithoutUsage === 0;
|
|
966
|
+
return {
|
|
967
|
+
requestedModel: model,
|
|
968
|
+
identityStatus: identity.observedModel === null
|
|
969
|
+
? 'MODEL_IDENTITY_UNVERIFIED'
|
|
970
|
+
: identity.observedModel === model ? 'VERIFIED' : 'MODEL_IDENTITY_MISMATCH',
|
|
971
|
+
observedModel: identity.observedModel,
|
|
972
|
+
observedModelProviderId: identity.observedModelProviderId,
|
|
973
|
+
identityEvidenceSource: identity.evidenceSource,
|
|
974
|
+
cliVersion: identity.cliVersion,
|
|
975
|
+
sessions: sessionRecords.slice(),
|
|
976
|
+
rounds,
|
|
977
|
+
execProcesses: roundRecords.length,
|
|
978
|
+
toolCallsObserved,
|
|
979
|
+
malformedLines,
|
|
980
|
+
tokens,
|
|
981
|
+
roundRecords: roundRecords.slice(),
|
|
982
|
+
ledger: hostLedger ? hostLedger.snapshot() : null,
|
|
983
|
+
budgetSemantics: 'process-level hard caps pre-spawn; token totals are OBSERVED host feedback gating the next spawn — not a pre-send USD/HTTP limit',
|
|
984
|
+
};
|
|
985
|
+
},
|
|
986
|
+
};
|
|
987
|
+
}
|