Skip to content
Closed
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
18 changes: 17 additions & 1 deletion src/adapters/codex-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,24 @@ export interface CodexLine {
author?: string
recipient?: string
namespace?: string
/** `user_message` event text. */
/** `user_message` event text, and the summary on a `compacted` record. */
message?: unknown
/**
* Conversation history a `compacted` record retained, in response-item
* shape. Codex writes the pre-compaction turns here and nowhere else, so
* this is the only surviving copy of what the human typed before the
* context was replaced.
*/
replacement_history?: ReadonlyArray<{
type?: string
id?: string
role?: string
content?: unknown
}>
window_id?: string
previous_window_id?: string
first_window_id?: string
window_number?: number
item?: {
type?: string
id?: string
Expand Down
242 changes: 237 additions & 5 deletions src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,58 @@ import {
isCodexTaskBoundary,
resolveCodexParentTask,
} from './codex-task-scope.js'
import {
INHERITED_SOURCE_ATTR,
INHERITED_SPAN_ATTR,
INHERITED_SPAN_COUNT_ATTR,
INHERITED_SPANS_OMITTED_ATTR,
type InheritedSpanSource,
isInheritedSpan,
SYNTHESIZED_SOURCE_ATTR,
SYNTHESIZED_SPAN_ATTR,
} from './provenance.js'
import { INNER_TOOL_CALL_LEVEL, recordToolOutput, TOOL_CALL_LEVEL_ATTR, toolIoAttributes } from './tool-io.js'

export { CodexTaskScopeError } from './codex-task-scope.js'

const SERVICE = 'codex'
const SESSION_HEAD_LINES = 40

/**
* Per-session ceiling on inherited spans. The prefix of a fork and the retained
* history of every `compacted` record are unbounded in a long rollout, and this
* adapter parses under a bounded heap. What the cap drops is counted on the root
* in `traces.session.inherited_spans_omitted`, never silently discarded.
*/
const MAX_INHERITED_SPANS = 200

const CODEX_SOURCE_TRACE_ID = 'traces.codex.source_trace_id'
const CODEX_SOURCE_SPAN_ID = 'traces.codex.source_span_id'
const CODEX_SOURCE_PARENT_SPAN_ID = 'traces.codex.source_parent_span_id'

/**
* The harness's own cumulative token counter, carried verbatim.
*
* Codex reports `token_count.info.total_token_usage` beside the per-turn
* `last_token_usage` delta. The two are different numbers and neither derives
* the other: summing the deltas misses whatever the harness counted outside the
* recorded turns, and summing the cumulative snapshots multiplies the session
* total by the number of events. The last snapshot IS the session total, so it
* is copied onto the root span and never recomputed here.
*/
const SESSION_TOTAL_TOKENS = 'traces.session.total_tokens'
const SESSION_TOTAL_INPUT_TOKENS = 'traces.session.total_input_tokens'
const SESSION_TOTAL_OUTPUT_TOKENS = 'traces.session.total_output_tokens'
const SESSION_TOTAL_REASONING_TOKENS = 'traces.session.total_reasoning_tokens'
const SESSION_TOTAL_CACHED_INPUT_TOKENS = 'traces.session.total_cached_input_tokens'
const SESSION_TOTAL_TOKENS_SOURCE = 'traces.session.total_tokens_source'
const CODEX_TOTAL_TOKENS_SOURCE = 'codex.token_count.info.total_token_usage'

/** Carry a reported counter only when it is a usable non-negative number. */
function reportedCount(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
}

/** Convert Codex's readable span identities to fixed-width OTLP wire IDs. */
function normalizeCodexIds(spans: OtlpSpan[]): void {
for (const item of spans) {
Expand Down Expand Up @@ -852,7 +893,11 @@ export class CodexAdapter implements HarnessTraceAdapter {
let step = 0
let lastLlm = rootId
let sawUserTurn = false
let sawInheritedTurn = false
let inheritedSpansEmitted = 0
let inheritedSpansOmitted = 0
let lastCumulativeTokenUsage: string | undefined
let sessionTotalUsage: CodexTokenUsage | undefined
let lastTimestamp: string | undefined
const awaitingModel = model ? [] : [root]
const toolWindows = new Map<OtlpSpan, ToolWindow>()
Expand All @@ -868,6 +913,7 @@ export class CodexAdapter implements HarnessTraceAdapter {
const unpairedUserItems: UserTurnCandidate[] = []
const unpairedUserEvents: UserTurnCandidate[] = []
const tasksWithUserEvents = new Set<number>()
const inheritedTurnKeys = new Set<string>()
/**
* Record one turn Codex reports as submitted input. Codex reports these
* turns and never its own context blocks: the legacy `user_message` event
Expand Down Expand Up @@ -908,6 +954,143 @@ export class CodexAdapter implements HarnessTraceAdapter {
unpairedUserEvents.push({ span: turnSpan, key, task: taskIndex })
step += 1
}
/** Reserve a slot for one inherited span, counting what the cap turns away. */
const claimInheritedSpan = (): boolean => {
if (inheritedSpansEmitted >= MAX_INHERITED_SPANS) {
inheritedSpansOmitted += 1
return false
}
inheritedSpansEmitted += 1
return true
}
/**
* A turn this session carries but did not receive: the prefix a fork copies
* from its parent, and the history a `compacted` record retains. Codex
* rewrites both into the child's rollout, and the task-scope walk used to
* drop them, so a forked child's human context reached no span at all and
* "what did the human ask for?" had no answer in the trace.
*
* The span is a normal `user.prompt` with its real actor, plus
* `traces.session.inherited` — a reader that wants the human's words finds
* them by name, and a count of THIS scope's turns excludes them by flag.
*
* Deduplicated on the turn text, because one retained turn is rewritten
* into every later `compacted` record. The key is bounded (length plus a
* text prefix) so a long session cannot grow the key set without bound.
*/
const recordInheritedTurn = (
raw: string,
ts: string,
contentSource: SourceReferences,
source: InheritedSpanSource,
blocks?: readonly string[],
): void => {
const prompt = capText(raw)
if (!prompt) return
const key = userTurnKey(raw)
const dedupKey = `${key.length}:${key.slice(0, 256)}`
if (inheritedTurnKeys.has(dedupKey)) return
if (!claimInheritedSpan()) return
inheritedTurnKeys.add(dedupKey)
const actor = codexActor({ text: prompt, blocks, isFirstUserTurn: !sawInheritedTurn })
sawInheritedTurn = true
const turnSpan = userPromptSpan({
traceId,
spanId: `inherited:${step}:user`,
parentSpanId: rootId,
startTime: ts,
content: prompt,
contentSource,
service: SERVICE,
agent: SERVICE,
step,
actor,
})
turnSpan.attributes[INHERITED_SPAN_ATTR] = true
turnSpan.attributes[INHERITED_SOURCE_ATTR] = source
spans.push(turnSpan)
step += 1
}
/**
* Records the parsed scope inherited rather than produced. Two shapes reach
* here: any line before the selected task boundary (the fork prefix), and a
* `compacted` record anywhere in the file. A compacted record carries the
* summary Codex replaced the context with, plus the history it retained.
*/
const recordInheritedContext = (l: CodexLine, ts: string): void => {
if (l.type === 'compacted') {
const payload = l.payload
if (!payload) return
const summary = capText(typeof payload.message === 'string' ? payload.message : '')
if (summary && claimInheritedSpan()) {
spans.push(span({
traceId,
spanId: `inherited:${step}:compacted`,
parentSpanId: rootId,
name: 'session.compacted',
kind: 'CHAIN',
startTime: ts,
service: SERVICE,
agent: SERVICE,
step,
content: summary,
contentSource: textSources(payload, 'message'),
extra: {
[INHERITED_SPAN_ATTR]: true,
[INHERITED_SOURCE_ATTR]: 'compacted' satisfies InheritedSpanSource,
...(typeof payload.window_number === 'number' ? { 'traces.codex.compaction_window_number': payload.window_number } : {}),
...(payload.window_id ? { 'traces.codex.compaction_window_id': payload.window_id } : {}),
...(payload.previous_window_id ? { 'traces.codex.compaction_previous_window_id': payload.previous_window_id } : {}),
},
}))
step += 1
}
for (const item of payload.replacement_history ?? []) {
if (!item || typeof item !== 'object') continue
if (item.type !== 'message' || item.role !== 'user') continue
recordInheritedTurn(
contentToString(item.content),
ts,
textSources(item, 'content'),
'compacted',
contentTextBlocks(item.content),
)
}
return
}
if (l.type === 'response_item' && l.payload?.type === 'message' && l.payload.role === 'user') {
recordInheritedTurn(
contentToString(l.payload.content),
ts,
textSources(l.payload, 'content'),
'pre-task-prefix',
contentTextBlocks(l.payload.content),
)
return
}
if (l.type === 'event_msg' && l.payload?.type === 'user_message') {
recordInheritedTurn(
typeof l.payload.message === 'string' ? l.payload.message : '',
ts,
textSources(l.payload, 'message'),
'pre-task-prefix',
)
return
}
// Only the turn shape is decoded here: a prefix `FileChange` item would
// parse a whole diff this walk never records.
if (l.type === 'event_msg' && l.payload?.type === 'item_completed' && l.payload.item?.type === 'UserMessage') {
const completed = codexCompletedItem(l)
if (completed?.type === 'UserMessage') {
recordInheritedTurn(
completed.userMessage.text,
ts,
textSources(completed.item, 'content'),
'pre-task-prefix',
)
}
}
}
const ensureSubagentSpan = (
threadId: string,
agentPath: string,
Expand Down Expand Up @@ -938,16 +1121,21 @@ export class CodexAdapter implements HarnessTraceAdapter {
return existing
}
const subagentType = agentPath.split('/').filter(Boolean).at(-1) ?? 'subagent'
// The child thread's lifecycle, assembled from `sub_agent_activity`
// events — NOT a call the model issued. It was a TOOL span named
// `tool.Agent`, so every tool-call count (here and in any consumer that
// counts TOOL spans or `tool.name`) ran high by one per child thread.
// It is an AGENT span with no `tool.name`, marked synthesized, so a
// counter needs no name allowlist to get the model's tool calls right.
const toolSpan = span({
traceId,
spanId: `subagent:${threadId}`,
parentSpanId: eventCallSpan?.span_id ?? lastLlm,
name: 'tool.Agent',
kind: 'TOOL',
name: 'subagent.lifecycle',
kind: 'AGENT',
startTime: eventTime,
service: SERVICE,
agent: SERVICE,
tool: 'Agent',
step,
status: 'UNSET',
extra: {
Expand All @@ -958,6 +1146,9 @@ export class CodexAdapter implements HarnessTraceAdapter {
agent_thread_id: threadId,
},
}),
[SYNTHESIZED_SPAN_ATTR]: true,
[SYNTHESIZED_SOURCE_ATTR]: 'codex.sub_agent_activity',
'traces.codex.subagent_type': subagentType,
'traces.codex.subagent_path': agentPath,
'traces.codex.subagent_thread_id': threadId,
...(!observedStart ? { 'traces.codex.subagent_start_missing': true } : {}),
Expand All @@ -971,9 +1162,14 @@ export class CodexAdapter implements HarnessTraceAdapter {
}

let reachedCurrentTask = !selectedBoundary
let prefixTimestamp: string | undefined
for await (const l of readJsonl<CodexLine>(ref.path, jsonl)) {
if (!reachedCurrentTask) {
if (!isCodexTaskBoundary(l, selectedBoundary!)) continue
if (!isCodexTaskBoundary(l, selectedBoundary!)) {
prefixTimestamp = validTimestamp(l.timestamp) ?? prefixTimestamp
recordInheritedContext(l, prefixTimestamp ?? root.start_time)
continue
}
reachedCurrentTask = true
if (options.taskScope === 'turn') {
root.start_time = codexTaskBoundary(l)?.timestamp ?? root.start_time
Expand Down Expand Up @@ -1006,6 +1202,13 @@ export class CodexAdapter implements HarnessTraceAdapter {
awaitingModel.length = 0
} else if (l.type === 'event_msg' && l.payload?.type === 'token_count') {
const u = l.payload.info?.last_token_usage
// Read before the delta gate: a `token_count` event that reports no
// per-turn delta still advances the harness's cumulative counter, and
// the last snapshot in scope is the session total.
const reportedTotal = l.payload.info?.total_token_usage
if (reportedTotal && reportedCount(reportedTotal.total_tokens) !== undefined) {
sessionTotalUsage = reportedTotal
}
if (u && (u.input_tokens || u.output_tokens)) {
const cumulative = l.payload.info?.total_token_usage
const cumulativeSignature = cumulative ? tokenUsageSignature(cumulative) : undefined
Expand Down Expand Up @@ -1033,6 +1236,10 @@ export class CodexAdapter implements HarnessTraceAdapter {
lastLlm = id
step += 1
}
} else if (l.type === 'compacted') {
// Compaction replaces the model's context with a summary and a retained
// history. Both are inherited context, not new turns in this scope.
recordInheritedContext(l, ts)
} else if (
l.type === 'response_item' &&
(l.payload?.type === 'function_call' || l.payload?.type === 'custom_tool_call')
Expand Down Expand Up @@ -1293,8 +1500,33 @@ export class CodexAdapter implements HarnessTraceAdapter {
Object.fromEntries([...skippedItemCounts].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))),
)
}
if (sessionTotalUsage) {
const total = reportedCount(sessionTotalUsage.total_tokens)
if (total !== undefined) {
root.attributes[SESSION_TOTAL_TOKENS] = total
root.attributes[SESSION_TOTAL_TOKENS_SOURCE] = CODEX_TOTAL_TOKENS_SOURCE
// The rest of the same snapshot, so the parts and the total agree.
const input = reportedCount(sessionTotalUsage.input_tokens)
const output = reportedCount(sessionTotalUsage.output_tokens)
const reasoning = reportedCount(sessionTotalUsage.reasoning_output_tokens)
const cached = reportedCount(sessionTotalUsage.cached_input_tokens)
if (input !== undefined) root.attributes[SESSION_TOTAL_INPUT_TOKENS] = input
if (output !== undefined) root.attributes[SESSION_TOTAL_OUTPUT_TOKENS] = output
if (reasoning !== undefined) root.attributes[SESSION_TOTAL_REASONING_TOKENS] = reasoning
if (cached !== undefined) root.attributes[SESSION_TOTAL_CACHED_INPUT_TOKENS] = cached
}
}
if (inheritedSpansEmitted > 0) root.attributes[INHERITED_SPAN_COUNT_ATTR] = inheritedSpansEmitted
// A dropped record stays visible as a count: inherited context is bounded,
// and "the cap was hit" must not read as "there was nothing before this".
if (inheritedSpansOmitted > 0) root.attributes[INHERITED_SPANS_OMITTED_ATTR] = inheritedSpansOmitted
if (selectedBoundary?.turnId) {
for (const item of spans) item.attributes['traces.codex.turn_id'] ??= selectedBoundary.turnId
// An inherited record predates the selected turn; stamping it with that
// turn id would claim it happened inside the turn.
for (const item of spans) {
if (isInheritedSpan(item.attributes)) continue
item.attributes['traces.codex.turn_id'] ??= selectedBoundary.turnId
}
}
closeSpanAt(root, lastTimestamp ?? root.start_time)
normalizeCodexIds(spans)
Expand Down
Loading