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
69 changes: 68 additions & 1 deletion .claude/notes/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,8 @@ Codex runs every sub-agent on its own child thread whose events never reach the
stream, and that thread persists with *Limited* rollout policy, which drops
`commandExecution` events. So neither the live stream nor `thread.read` surfaces the
sub-agent's shell commands, and `thread/tokenUsage/updated` only ever reports the PARENT
thread.
thread. (The commands a code-mode script runs are the exception: they persist as
`item_completed` records, below.)

But the child rollout ALWAYS persists the raw `function_call` / `local_shell_call` /
`custom_tool_call` ResponseItems and a `token_count` event with the child's cumulative
Expand All @@ -395,6 +396,72 @@ The rollout file can lag the parent's `turn/completed` by a beat (the recorder f
a background task), so the lookup polls ~2 s — but bails immediately when
`<home>/sessions` does not exist at all, since no flush can ever land there.

Codex multi-agent v2 moved the spawn off the collab item. A v1 spawn is a
`collabAgentToolCall` that names the child in `receiver_thread_ids`; a v2 spawn is a
`subAgentActivity` item with `kind: started`, `id` = the spawn call id and
`agent_thread_id` = the child, and its `wait` names no child at all. `_tool_item_root`
maps a `started` item onto the v1 spawn shape where items enter the turn state, so one
path registers both, and registers a child once: a repeated spawn item would otherwise
mine the rollout twice and double the child's rows and tokens. The other kinds
(`completed`, `interrupted`, `interacted`) are notices about a spawned child, dropped
there, so they neither become rows nor open a parent model call.

A child's tool calls come in two shapes. A direct call is a function call, and its output
header carries the exit code. A code-mode call is a custom tool `exec` whose input is a
JavaScript script, often several shell calls in a `Promise.all`. Its output tells only
whether the SCRIPT ran to the end: `tools.exec_command` hands a non-zero exit code back to
the script and does not throw, so `Script completed` is not a command result. The script
source is not parsed — a command built from a variable or a loop defeats any parser, and
the source holds no exit code. Instead, while a script runs, Codex writes an
`item_completed` record per shell command (`CommandExecution`: argv, exit code, output)
and per patch (`FileChange`), and those records ARE the script's rows, each with its own
result. A script that left no record stays one `exec` row, not a `Bash` row: its
JavaScript is not a shell command. The argv's login-shell wrapper (`/bin/zsh -lc <cmd>`)
is stripped, so a record's row reads like a direct call's.

A record names no script, so it goes to the newest script whose source holds its
command. A command can outlive its script: `tools.exec_command` returns at the call's own
`yield_time_ms` with a session id, the script ends, and a later script polls the command
with `tools.write_stdin`. The text match then gives the record to the script that started
the command, so that script is not also left as an `exec` row that repeats the command.
The match decodes the source's string escapes and then ignores backslashes. A multi-line
command is a `"a\nb"` literal in the source but holds real newlines in its record; in a
recorded 0.147 TUI thread, 3 of 10 such commands outlived their script and, undecoded,
went to a later one. The source also escapes the backslashes and double quotes that the
command holds; without ignoring backslashes, no record of a real 0.156.1 Windows e2e child
matched. A record that matches no source goes to the newest script: dropping it
would lose a command built from a variable, the miss this recovery exists to prevent,
while a wrong guess only moves the row to another generation. A record whose id is a
direct call's is dropped, so a direct call is never counted twice. Seen on real CLI
0.156.1 runs, v1 and v2 children alike: the records sit between the script call and its
output, before the generation's `token_count`, and a 45-second command was polled as
described. Headless threads through 0.147 (the 0.144 SDK, 0.147 `codex exec`) write no
`CommandExecution` records, so there a code-mode script stays one `exec` row and its
commands are not recoverable. The 0.147 TUI and 0.155.1 (the SDK floor) write them, as do
0.156.1 and 0.162, on Windows too.

A call with no result is `unknown`, never a success, so `require_success` does not credit
it: an interrupt cut it off, it still ran after its yield time, or `wait` stopped it. The
abort output is matched whole, so a command that prints `aborted by user` keeps its exit
code. The `wait` or `write_stdin` call that later returns the result is a row of its own
and does not change the yielded call: the records already carry each command's exit code,
so only a yielded script that ran no command, or a direct `exec_command` that a direct
`write_stdin` polls (no recorded run has one), stays `unknown`. A resuming call stays a
row because a generation that only resumed would otherwise hold no block, and the recovery
drops such a generation with its tokens; so each poll is one row, where the parent stream
records a polled command once. The abort forms come from `abort_message` in
`core/src/tools/parallel.rs` (only `exec_command` gets the wall time). A script yields
after `DEFAULT_EXEC_YIELD_TIME_MS` (10 s); about 8% of the scripts in recorded 0.144 to
0.147 threads yielded.

A spawn with `fork_turns` copies the parent's history, tool calls included, into the head
of the child's rollout. In a forked rollout (`session_meta` carries `forked_from_id`),
parsing starts at the first `thread_settings_applied` with the child's thread id, so the
parent's commands are not credited to the child. A non-forked rollout is parsed whole:
that event also appears mid-thread on a settings change. A fork without that event is
also parsed whole, with a warning, so a format change that would credit the parent's
commands to the child shows in the logs.

The thread-cumulative baseline is the other half. `ThreadTokenUsage.total` counts the whole
THREAD, and the Codex thread is created once per task and reused, so by turn N it still
carries turns 1..N-1. The orchestrator sums per-turn usages, so handing it the cumulative
Expand Down
24 changes: 24 additions & 0 deletions docs/agents/CODEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,30 @@ The agent sets up SKILL.md files (Agent Skills open standard) in `.agents/skills
5. Codex CLI auto-discovers skills in `.agents/skills/`
6. Removes the copies when the turn ends, so criteria and run artifacts never see them

### Sub-agents

A sub-agent runs on its own Codex thread, and its tool calls never reach the parent's
stream. After each turn the agent reads the child's rollout file under
`$CODEX_HOME/sessions` and records the child's calls as telemetry, nested under the
spawning `Agent` row. Spawns from multi-agent v1 (`spawnAgent`) and v2
(`subAgentActivity`) are both recovered.

- Each shell command the child ran is one `Bash` row with its own exit code, also when it
runs longer than its script. A code-mode script that runs several commands gives one row
per command.
- A non-zero exit is `result_status: error`. A call that Codex reports with no result is
`result_status: unknown`: an interrupt stopped it, or it still ran when its tool call
returned. `command_executed` with `require_success: true` counts neither. A later `wait`
or `write_stdin` call is a row of its own and does not change that status.
- Each file the child patched is a `Write` row.
- A code-mode `exec` script that ran no command and changed no file (for example, one
that only reads the tool list) is one `exec` row, not a `Bash` row.
- Headless Codex through 0.147 writes no command records; 0.155.1 (the SDK floor),
0.156.1 and 0.162 do. On a CLI without records a code-mode script is one `exec`
row, and the commands inside it are not recovered.
- A multi-agent v2 spawn names no model, so the child's generations are priced at the
parent's model.

### Async Integration

The Codex SDK is synchronous. The agent uses `_run_async()` helper to detect and await coroutines, preserving the async interface.
Expand Down
12 changes: 12 additions & 0 deletions docs/agents/HARNESS_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,18 @@ what that cell means: measured, but not placeable. On historical codex runs, thi
moves ~8 h in aggregate out of Tool exec and into Unaccounted. No built-in agent
writes this shape now.

## Sub-agent tool calls

Only claude-code and codex nest a sub-agent's work under its spawn row
(`parent_tool_use_id`); the other harnesses record no sub-agent path.

| | claude-code | codex |
|---|---|---|
| spawn row | `Agent`, with the brief in `description` / `prompt` | `Agent` (`operation: spawnagent`), from a v1 `collabAgentToolCall` or a v2 `subAgentActivity` `started` item; on v2, no prompt (the spawn message is encrypted) and no model |
| the child's generations | nested live, with the child's real model | nested after the turn, rebuilt from the child's rollout under `$CODEX_HOME/sessions`, priced at the parent's model on v2 |
| the child's shell commands | one `Bash` row per call, status and `duration_ms` measured live | one `Bash` row per command, with its exit code, rebuilt from the child's rollout (CLI 0.156.1 and later); no `duration_ms`. See [Codex § Sub-agents](CODEX.md#sub-agents) |
| when the rows exist | during the turn, so live criteria see them | after the turn, so live criteria and `stop_early` do not see them |

## `max_turns` counts model API calls on every harness

One turn is one main-thread model API call, the unit Claude Code's `--max-turns`
Expand Down
Loading
Loading