Files
bitvid/tools/fleet/adapters/codex-protocol.mjs
T

132 lines
5.1 KiB
JavaScript

// adapters/codex-protocol.mjs — turn Codex `exec --json` output into one outcome.
//
// Parsing facts ported from TORCH src/runtime/codex-protocol-reducer.mjs at 11aec08
// (PR0M3TH3AN/TORCH, rewrite/portable-agent-fleet): a 32 KiB line cap, the first
// thread.started id of at most 256 characters, and turn.completed versus
// turn.failed / turn.error / error. That reducer is a subprocess wrapper; this
// file is a pure function because the kit already runs the child in bin/lib.mjs.
//
// Stricter than TORCH: any dropped oversized line, or a final fragment that
// starts with "{" or "[" and does not parse, is truncated, and truncated is
// not success even if turn.completed appeared earlier.
//
// Needs-input types are not classified by that TORCH reducer. The names are the
// snake_case EventMsg tags in openai/codex protocol.rs (ExecApprovalRequest,
// ApplyPatchApprovalRequest, RequestUserInput, RequestPermissions) plus the
// app-server methods that end in /requestApproval or /requestUserInput.
// request_user_input_async and an in-progress command_execution item are not
// needs-input. These events were not captured from a live exec stream.
import { baseVerdict } from './contract.mjs';
export const MAX_PROTOCOL_LINE_CHARS = 32 * 1024;
const NEEDS_INPUT = new Set([
'exec_approval_request',
'apply_patch_approval_request',
'request_user_input',
'request_permissions',
]);
function needsInput(record) {
if (NEEDS_INPUT.has(record?.type)) return true;
const method = record?.method;
return typeof method === 'string'
&& (method.endsWith('/requestApproval') || method.endsWith('/requestUserInput'));
}
function threadIdOf(record) {
const id = record?.thread_id ?? record?.threadId;
return typeof id === 'string' && id.length > 0 && id.length <= 256 ? id : null;
}
/** Last agent_message text in a Codex exec --json stream, or null. */
export function codexAgentText(text) {
let last = null;
for (const line of String(text ?? '').split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.startsWith('{')) continue;
let record;
try { record = JSON.parse(trimmed); } catch { continue; }
const item = record?.item;
if (record?.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string' && item.text.trim()) {
last = item.text;
}
}
return last;
}
/** Scan one Codex JSONL buffer. Later terminal events replace earlier ones. */
export function scanCodexOutput(text) {
const src = String(text ?? '');
let sessionId = null;
let terminal = null;
let failureType = null;
let truncated = false;
let pending = '';
let dropping = false;
const apply = (record) => {
if (!sessionId && record?.type === 'thread.started') sessionId = threadIdOf(record);
if (record?.type === 'turn.completed') terminal = 'turn.completed';
else if (record?.type === 'turn.failed' || record?.type === 'turn.error' || record?.type === 'error') {
terminal = 'failure';
failureType = record.type;
} else if (needsInput(record)) terminal = 'needs-input';
};
const takeLine = (line) => {
const trimmed = line.trim();
if (!trimmed) return;
try { apply(JSON.parse(trimmed)); } catch { /* diagnostic text */ }
};
let cursor = 0;
while (cursor < src.length) {
const newline = src.indexOf('\n', cursor);
const end = newline < 0 ? src.length : newline;
const fragment = src.slice(cursor, end);
if (!dropping) {
if (pending.length + fragment.length > MAX_PROTOCOL_LINE_CHARS) {
pending = '';
dropping = true;
truncated = true;
} else pending += fragment;
}
if (newline < 0) break;
if (!dropping) takeLine(pending);
pending = '';
dropping = false;
cursor = newline + 1;
}
if (dropping) truncated = true;
else if (pending.trim()) {
const trimmed = pending.trim();
try { apply(JSON.parse(trimmed)); }
catch { if (trimmed.startsWith('{') || trimmed.startsWith('[')) truncated = true; }
}
return { sessionId, terminal, failureType, truncated };
}
/**
* Definite outcome for one headless Codex run.
* Timeout, a signal, and a missing exit win first. Then truncation.
* Exit 0 is success only when the last terminal event is turn.completed.
*/
export function reduceCodexOutput(output, meta = {}) {
const parsed = scanCodexOutput(output);
const sessionId = parsed.sessionId;
const base = baseVerdict(meta);
if (base) {
const outcome = base.reason === 'timeout' ? 'timeout' : 'failure';
return { ...base, ok: false, outcome, sessionId };
}
if (parsed.truncated) return { ok: false, outcome: 'truncated', reason: 'truncated', sessionId };
if (meta.status !== 0) return { ok: false, outcome: 'failure', reason: `exit:${meta.status}`, sessionId };
if (parsed.terminal === 'turn.completed') return { ok: true, outcome: 'success', reason: 'turn.completed', sessionId };
if (parsed.terminal === 'needs-input') return { ok: false, outcome: 'needs-input', reason: 'needs-input', sessionId };
if (parsed.terminal === 'failure') return { ok: false, outcome: 'failure', reason: parsed.failureType, sessionId };
return { ok: false, outcome: 'failure', reason: 'no-completed-turn', sessionId };
}