mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
132 lines
5.1 KiB
JavaScript
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 };
|
|
}
|