Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions src/adapters/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,63 @@ export function claudeActor(args: {
return 'human'
}

/**
* Context blocks Codex writes as user-role messages and never reports as a user
* turn. The list mirrors `CONTEXTUAL_USER_FRAGMENT_MATCHERS` (openai/codex
* `codex-rs/core/src/context/contextual_user_message.rs`); the default matcher
* accepts a trimmed block that starts with the open marker and ends with the
* close marker, ignoring ASCII case (openai/codex
* `codex-rs/context-fragments/src/fragment.rs`).
*/
const CODEX_CONTEXT_BLOCKS: ReadonlyArray<readonly [open: string, close: string]> = [
['# AGENTS.md instructions', '</INSTRUCTIONS>'],
['<user_instructions>', '</user_instructions>'],
['<environment_context>', '</environment_context>'],
['<skills_instructions>', '</skills_instructions>'],
['<user_shell_command>', '</user_shell_command>'],
['<turn_aborted>', '</turn_aborted>'],
['<subagent_notification>', '</subagent_notification>'],
['<codex_internal_context', '</codex_internal_context>'],
['<goal_context>', '</goal_context>'],
['<recommended_plugins>', '</recommended_plugins>'],
]

/** Harness warnings Codex also injects as user-role text, matched by prefix. */
const CODEX_CONTEXT_PREFIXES = [
'Warning: apply_patch was requested via ',
'Warning: Your account was flagged for potentially high-risk cyber activity',
'Warning: The maximum number of unified exec processes you can keep open is',
] as const

const CODEX_EXTERNAL_CONTEXT = /^<external_([^>]+)>[\s\S]*<\/external_\1>$/

function startsWithIgnoringCase(text: string, prefix: string): boolean {
return text.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase()
}

function endsWithIgnoringCase(text: string, suffix: string): boolean {
return text.slice(-suffix.length).toLowerCase() === suffix.toLowerCase()
}

/** Whether one text block of a Codex user-role message is harness context. */
export function isCodexContextBlock(block: string): boolean {
const text = block.trim()
if (CODEX_CONTEXT_PREFIXES.some((prefix) => text.startsWith(prefix))) return true
if (CODEX_EXTERNAL_CONTEXT.test(text)) return true
return CODEX_CONTEXT_BLOCKS.some(
([open, close]) => startsWithIgnoringCase(text, open) && endsWithIgnoringCase(text, close),
)
}

/**
* Derive the actor for a Codex user message. Codex has no sidechain/userType,
* so it's text-only: synthetic markers → injected, first-turn agent-spawn
* brief → injected, otherwise human.
* so it's text-only: a Codex context block → injected, synthetic markers →
* injected, first-turn agent-spawn brief → injected, otherwise human.
* `blocks` are the message's separate text blocks; Codex treats the whole
* message as context when any one block is.
*/
export function codexActor(args: { text: string; isFirstUserTurn?: boolean }): Actor {
export function codexActor(args: { text: string; blocks?: readonly string[]; isFirstUserTurn?: boolean }): Actor {
if ((args.blocks ?? [args.text]).some(isCodexContextBlock)) return 'injected'
if (textIsCmdOrInject(args.text)) return 'injected'
if (textIsSynthetic(args.text)) return 'injected'
if (args.isFirstUserTurn && looksLikeAgentPrompt(args.text)) return 'injected'
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/codex-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,9 @@ export class CodexExecAdapter implements HarnessTraceAdapter {
pending.span.attributes['traces.codex.exec_item_status'] = item.status
if (result.exitCode !== undefined) {
pending.span.attributes['traces.codex.exec_exit_code'] = result.exitCode
// One spelling for one fact: the rollout adapter's command spans use
// this key, so a reader of exit codes needs no per-adapter branch.
pending.span.attributes['process.exit_code'] = result.exitCode
}
if (type === 'command_execution') {
recordToolOutput(pending.span, typeof item.aggregated_output === 'string' ? item.aggregated_output : undefined, sourceOf(item, 'aggregated_output'))
Expand Down
204 changes: 204 additions & 0 deletions src/adapters/codex-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface CodexLine {
author?: string
recipient?: string
namespace?: string
/** `user_message` event text. */
message?: unknown
item?: {
type?: string
id?: string
Expand Down Expand Up @@ -121,6 +123,197 @@ export function codexSubagentActivity(line: CodexLine): CodexSubagentActivity |
}
}

/** A command Codex ran, from an `item_completed` event whose item is `CommandExecution`. */
export interface CodexCommandExecution {
readonly itemId: string
/** Recorded argv (current builds) or command string, verbatim. */
readonly command: readonly string[] | string
readonly cwd?: string
readonly processId?: string
readonly source?: string
readonly status?: string
readonly exitCode?: number
readonly output?: string | { readonly stdout?: string; readonly stderr?: string }
readonly outputFields: readonly string[]
readonly startedAtMs?: number
readonly completedAtMs?: number
}

export interface CodexFileChangeEntry {
readonly path: string
/** Codex's change type (`add`, `delete`, `update`), verbatim. */
readonly kind: string
readonly movePath?: string
}

/** Files a patch changed, from an `item_completed` event whose item is `FileChange`. */
export interface CodexFileChange {
readonly itemId: string
readonly changes: readonly CodexFileChangeEntry[]
readonly status?: string
readonly startedAtMs?: number
readonly completedAtMs?: number
}

/** A turn a client submitted, from an `item_completed` event whose item is `UserMessage`. */
export interface CodexUserMessage {
readonly itemId: string
readonly text: string
}

/**
* One `item_completed` event, normalized.
*
* `skipped` separates the two reasons an item produces no span, because they
* carry opposite meanings for a reader counting facts:
*
* - `unmodeled` — the adapter builds no span from this item type. The rollout
* records the same work as a `response_item` (reasoning, assistant messages,
* tool calls), and that record is what becomes a span, so the count is a
* census of item types, not missing facts.
* - `dropped` — an item of a modeled type produced no span: a required field
* was missing or mistyped, the item repeats one already recorded, or a user
* message carried no text. This count is the one that reads as lost facts.
*/
export type CodexCompletedItem =
| { readonly type: 'CommandExecution'; readonly item: object; readonly command: CodexCommandExecution }
| { readonly type: 'FileChange'; readonly item: object; readonly fileChange: CodexFileChange }
| { readonly type: 'UserMessage'; readonly item: object; readonly userMessage: CodexUserMessage }
| { readonly type: 'skipped'; readonly reason: 'unmodeled' | 'dropped'; readonly label: string }

type JsonRecord = Record<string, unknown>

function recordValue(value: unknown): JsonRecord | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : undefined
}

function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}

/** Codex defaults a missing `completed_at_ms` to 0, so only a positive time is a recorded time. */
function epochMs(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
}

function commandValue(value: unknown): readonly string[] | string | undefined {
if (typeof value === 'string') return value.length > 0 ? value : undefined
if (!Array.isArray(value) || value.length === 0) return undefined
return value.every((part) => typeof part === 'string') ? value as string[] : undefined
}

function commandOutput(item: JsonRecord): Pick<CodexCommandExecution, 'output' | 'outputFields'> {
if (typeof item.aggregated_output === 'string') {
return { output: item.aggregated_output, outputFields: ['aggregated_output'] }
}
const stdout = typeof item.stdout === 'string' ? item.stdout : undefined
const stderr = typeof item.stderr === 'string' ? item.stderr : undefined
if (stdout === undefined && stderr === undefined) return { outputFields: [] }
return {
output: { ...(stdout === undefined ? {} : { stdout }), ...(stderr === undefined ? {} : { stderr }) },
outputFields: [...(stdout === undefined ? [] : ['stdout']), ...(stderr === undefined ? [] : ['stderr'])],
}
}

/** The rollout records a map keyed by path; `codex exec --json` records an array of `{path, kind}`. */
function fileChangeEntries(value: unknown): CodexFileChangeEntry[] | undefined {
const entries: CodexFileChangeEntry[] = []
if (Array.isArray(value)) {
for (const raw of value) {
const change = recordValue(raw)
const path = nonEmptyString(change?.path)
const kind = nonEmptyString(change?.kind) ?? nonEmptyString(change?.type)
if (!path || !kind) return undefined
const movePath = nonEmptyString(change?.move_path)
entries.push({ path, kind, ...(movePath ? { movePath } : {}) })
}
} else {
const changes = recordValue(value)
if (!changes) return undefined
for (const [path, raw] of Object.entries(changes)) {
const change = recordValue(raw)
const kind = nonEmptyString(change?.type) ?? nonEmptyString(change?.kind)
if (path.length === 0 || !kind) return undefined
const movePath = nonEmptyString(change?.move_path)
entries.push({ path, kind, ...(movePath ? { movePath } : {}) })
}
}
if (entries.length === 0) return undefined
return entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
}

/** The text a `UserMessage` item carries; other input parts (images, audio) have no text. */
function userMessageText(content: unknown): string {
if (!Array.isArray(content)) return ''
return content
.map((part) => {
const entry = recordValue(part)
return entry?.type === 'text' && typeof entry.text === 'string' ? entry.text : ''
})
.join('')
}

/**
* Normalize Codex's `item_completed` event for the command, file-change and
* user-message items.
*
* Field names follow `CommandExecutionItem`, `FileChangeItem`, `UserMessageItem`
* and `ItemCompletedEvent` in openai/codex `codex-rs/protocol`. The format drifts,
* so a required field that is missing or mistyped yields a counted `skipped`
* label rather than a span built from guessed values.
*/
export function codexCompletedItem(line: CodexLine): CodexCompletedItem | undefined {
if (line.type !== 'event_msg' || line.payload?.type !== 'item_completed') return undefined
const payload = line.payload as JsonRecord
const item = recordValue(payload.item)
const type = nonEmptyString(item?.type) ?? 'unknown'
if (!item || (type !== 'CommandExecution' && type !== 'FileChange' && type !== 'UserMessage')) {
return { type: 'skipped', reason: 'unmodeled', label: type }
}
const itemId = nonEmptyString(item.id)
if (type === 'UserMessage') {
const text = userMessageText(item.content)
// An image-only or audio-only turn carries no text to record as a turn span.
if (!itemId || !text) return { type: 'skipped', reason: 'dropped', label: `${type}:${itemId ? 'no_text' : 'malformed'}` }
return { type, item, userMessage: { itemId, text } }
}
const startedAtMs = epochMs(payload.started_at_ms) ?? epochMs(item.started_at_ms)
const completedAtMs = epochMs(payload.completed_at_ms) ?? epochMs(item.completed_at_ms)
const status = nonEmptyString(item.status)
const times = {
...(startedAtMs === undefined ? {} : { startedAtMs }),
...(completedAtMs === undefined ? {} : { completedAtMs }),
}
if (type === 'FileChange') {
const changes = fileChangeEntries(item.changes)
if (!itemId || !changes) return { type: 'skipped', reason: 'dropped', label: `${type}:malformed` }
return { type, item, fileChange: { itemId, changes, ...(status ? { status } : {}), ...times } }
}
const command = commandValue(item.command)
if (!itemId || !command) return { type: 'skipped', reason: 'dropped', label: `${type}:malformed` }
const cwd = nonEmptyString(item.cwd)
const processId = typeof item.process_id === 'number' && Number.isSafeInteger(item.process_id)
? String(item.process_id)
: nonEmptyString(item.process_id)
const source = nonEmptyString(item.source)
const exitCode = typeof item.exit_code === 'number' && Number.isSafeInteger(item.exit_code) ? item.exit_code : undefined
return {
type,
item,
command: {
itemId,
command,
...(cwd ? { cwd } : {}),
...(processId ? { processId } : {}),
...(source ? { source } : {}),
...(status ? { status } : {}),
...(exitCode === undefined ? {} : { exitCode }),
...commandOutput(item),
...times,
},
}
}

export function contentToString(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
Expand All @@ -135,6 +328,17 @@ export function contentToString(content: unknown): string {
return ''
}

/** Each text block of a message, unjoined, so a per-block classifier sees block boundaries. */
export function contentTextBlocks(content: unknown): string[] {
if (typeof content === 'string') return [content]
if (!Array.isArray(content)) return []
return content.flatMap((item) => (
item && typeof item === 'object' && typeof (item as { text?: unknown }).text === 'string'
? [(item as { text: string }).text]
: []
))
}

export function timestampFromEpochMs(value: unknown): string | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
const date = new Date(value)
Expand Down
Loading