Skip to content

feat(codex_agent): run Codex in a task sandbox like a plain codex exec - #5156

Merged
gwarmstrong merged 11 commits into
NVIDIA-NeMo:mainfrom
gwarmstrong:georgea/codex-agent-plain-exec
Oct 28, 2026
Merged

gwarmstrong merged 11 commits into
NVIDIA-NeMo:mainfrom
gwarmstrong:georgea/codex-agent-plain-exec

Conversation

@gwarmstrong

@gwarmstrong gwarmstrong commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Benchmarks that grade a harness started inside the task container, as Harbor does, need Codex in a sandbox session to send the requests a plain codex exec sends there and to be graded the way Harbor grades it. Sandbox sessions refuse extra_config, so this adds explicit settings to codex_agent, all off by default:

  • Launch: model_server_forwards_reasoning_effort lets a configured reasoning_effort through once the operator has checked that the model server forwards it; inherit_sandbox_environment gives Codex the task image's environment and HOME; features leaves Codex's own feature defaults in place; request_max_retries and stream_max_retries set the provider's retries.
  • Grading: verify_after_agent_error returns a run that failed while its sandbox still runs and answers as a gradable failed response with no classified cause, so the Environment Server's outcome policy verifies it, as Harbor grades any agent exit. A lost sandbox, unconfirmed cleanup, a run that failed before its first turn or a model-endpoint error (Codex's 429/5xx, capacity, request-timeout, retry-limit and stream-disconnect messages, read from the failure that ended the run) stays an execution error and is retried. A provider failure that Codex reports itself (exit 1 on turn.failed) keeps the classified cause from fix(codex): retain session evidence and report model errors #4740, so the outcome policy decides it. A graded run reports Codex's native cumulative usage, or none when work happened that no usage record covers. Its observations are stored before the sandbox liveness check, so a session closed during the check keeps them. raise_oom_score_adj makes Codex and its tools the OOM killer's preferred victims.
  • Logs: sandbox_logs_dir copies Codex's session rollouts (per-call token counts and timestamps) to a directory the resources server collects. The copy runs after the run's events are read and within the capture time left, so a slow copy is cut short instead of failing the run.
  • Fix: a task image without WORKDIR lends / as its workdir, which sessions refused with HTTP 422. It is now accepted; the runtime and session files stay under /tmp, and any other workdir must still be disjoint from them.

No issue: opt-in settings on the existing sandbox sessions, plus a fix found in Terminal-Bench-Science runs.

Validation

  • codex_agent: 463 passed (7 skipped); pre-commit clean.
  • Earlier runs on an older main: Codex 0.154.0 with self-hosted DeepSeek V4.1 Flash on Terminal-Bench-Science, 70 tasks × 3 trials, 8 h, scored 31/210 against the public leaderboard's 33/210 for the same model and harness; Codex with GPT-6 Astra on 10 tasks scored 7/10 against stock Harbor's 7/9 on the same tasks.

Rollouts

N/A: agent settings. Profiles that use them come with the Terminal-Bench-Science harness profiles.

Compatibility and benchmark impact

Defaults unchanged, except that a workdir of / is now accepted instead of refused.

…ec does

Evaluating Codex in a task sandbox should send the requests the Codex
CLI sends when a harness such as Harbor 0.24 launches `codex exec`
directly in the task container. Sandbox sessions refuse extra_config, so
add explicit settings. Every default keeps the current behavior:

- model_server_forwards_reasoning_effort: sandbox sessions keep
  rejecting reasoning_effort by default, because most Gym model servers
  cannot guarantee it reaches the backend. The flag records the
  operator's check that the model server forwards Codex's `reasoning`
  field unchanged; the effective value is then visible in captured model
  calls.
- inherit_sandbox_environment: give Codex the sandbox's whole
  environment instead of PATH, the locale, HOME and the XDG directories,
  keeping only CODEX_HOME private. Task images set variables such as
  VIRTUAL_ENV, PYTHONHASHSEED or OMP_NUM_THREADS that a harness started
  in the container sees. Intended for remote providers, where that
  environment is the task's.
- features: the generated [features] table, which turns off multi_agent
  and code_mode by default. A null entry is left out, so the CLI default
  applies, as in a launch without a Gym config.
- ephemeral: false drops --ephemeral, so Codex keeps session state and
  offers its goal tools (create_goal, get_goal, update_goal). A correlated
  local rollout no longer tries to remove a flag that is not there.
- request_max_retries, stream_max_retries: the provider's resends before
  a stream starts and reconnects after one drops, beside
  stream_idle_timeout_ms; null leaves Codex's own defaults.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Harbor runs the verifier after any agent exit that is not a timeout: a
trial whose agent died while its sandbox still answers is graded, not
rerun. codex_agent instead raised an execution error for every failed
Codex run, so the Environment Server skipped verification and the
episode was retried from scratch: an agent the kernel's OOM kill hit
(once the sandbox kills a single process instead of itself), an agent
whose own pkill hit the runner, or a Codex exit 1.

verify_after_agent_error (off by default) returns such a run as a
`failed` response, with the error in metadata.graded_agent_error, which
Resources grades as a nonzero agent exit. It applies only when the
supervisor confirmed cleanup, the failure that ended the run names no
model-endpoint transport or 429/5xx error, and the sandbox still
reports running and answers a probe command. A lost or unresponsive
sandbox, an unconfirmed cleanup and an endpoint outage stay execution
errors and are retried.

The endpoint check reads only the terminal failure after the run's last
model output (turn.failed and the last error event before it, or the
last event when Codex was killed without turn.failed), so a retry that
recovered no longer turns a later unrelated failure into a rerun. It
recognizes Codex 0.154.0's messages: unexpected status 429/5xx, the
"high demand" message for a 500, exceeded retry limit, the
waiting-for-network notice, and stream disconnects from a transport
error, a closed stream, a failed send or the SSE idle timeout. The
output-cap disconnect (reason: max_output_tokens) stays gradable.

raise_oom_score_adj (off by default) makes Codex the OOM killer's
preferred victim in its sandbox. In Docker, the leaderboard's runtime,
the daemon that runs the agent lives outside the container's memory
cgroup, so an OOM kill can only pick the agent's own processes. In an
OpenSandbox sandbox the exec daemon and Gym's supervisor share the
cgroup and the oom_score_adj with Codex. The runner raises its own
score, which Codex and every tool inherit, to 1000, or to 0 when the
sandbox starts it below zero (a Guaranteed pod's -997), so the sandbox
never becomes the node's first victim in a global OOM. The exec daemon
and the supervisor keep the sandbox's own score.

TB4's resources-session test covers the `failed` response status: it is
graded as a nonzero agent exit.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
codex_agent removes its session directory, and with it CODEX_HOME, when
a sandbox session closes. Without the model-call capture, Gym then keeps
no per-call token counts for a Codex run: the response usage comes from
turn.completed, which a run stopped at its time limit never emits. With
the capture on, every request payload is also copied into the rollout
row (one image-heavy trial's row was 2.5 GB after 20 minutes), which a
full 210-trial run cannot hold in memory or on disk.

New sandbox_logs_dir setting: after Codex stops, the session rollouts
(CODEX_HOME/sessions/**/rollout-*.jsonl, with per-call token counts and
timestamps) are copied to <sandbox_logs_dir>/sessions before the session
directory is removed. A failed copy is logged and never fails
collection. Harbor's Codex agent copies them to /logs/agent/sessions the
same way; with /logs/agent, the TB4 resources server copies them into
the trial directory (agent/sessions/). It only acts without --ephemeral.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
A task image without WORKDIR (some Terminal-Bench-Science tasks) lends "/"
as its workdir. codex_agent refused it with HTTP 422 ("runtime/session
files must be outside SandboxAccess.workdir"), so every trial of those
tasks failed at session start, while Harbor runs the agent there.

The root holds every path, so it is accepted. The runtime and session
files stay under /tmp, and any other workdir must still be disjoint from
them: /tmp itself and paths under the adapter's /tmp prefixes are refused
as before, and the in-sandbox check still rejects a workdir that contains
or lies inside an adapter path after resolving symlinks.

Tests: six new cases in codex_agent/tests/test_sandbox_sessions.py cover
the root workdir (in-sandbox check and seeding) and the workdirs that are
still refused.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
… session state

Main's sandbox sessions always run Codex without --ephemeral, to keep its
native session transcript, so the setting no longer changes them. Local runs
keep main's --ephemeral.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
@copy-pr-bot

copy-pr-bot Bot commented Oct 27, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@gwarmstrong

Copy link
Copy Markdown
Contributor Author

/ok to test 74d7a2b

@svcnvidia-nemo-ci svcnvidia-nemo-ci added area:agent Agent harnesses and Responses API agent behavior feature New capabilities, enhancements, or enablement work needs-review PR is ready for code review and waiting on a reviewer labels Oct 27, 2026
@nemo-automation-bot
nemo-automation-bot Bot requested review from a team October 27, 2026 22:03
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

@svcnvidia-nemo-ci svcnvidia-nemo-ci left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HOLD

Found one BLOCKER and three RISK issues in the opt-in failure-grading and log-capture paths. Details and acceptance checks are inline.

Reviewed all eight changed files and their directly affected session-lifecycle, outcome-policy, verification, and usage-reporting consumers. Static inspection only; no tests or sandbox/model runs were executed.

Note: formal approval is not available for this request, so this review is posted as a comment only.


Inline comments (escaped v1):

  • responses_api_agents/codex_agent/app.py line 107: BLOCKER — Preserve retry handling for the supported CLI's timeout and capacity errors

    Failure: Enable verify_after_agent_error, replay turn.started followed by turn.failed with error.message="request timed out", and supply confirmed cleanup with return_code=-9, timed_out=False, and no cleanup error. On a live sandbox, _terminal_model_error declines the non-1 exit and _model_endpoint_error finds no marker, so the run becomes an unclassified HTTP-200 failed response. It is then verified rather than retried. The same occurs for Selected model is at capacity. Please try a different model. Both are existing supported endpoint failures in the native-error regression cases. A runner killed after the reported failure therefore lets an endpoint outage enter benchmark scores because unclassified failures verify.

    Correction: Include these native messages in _MODEL_ENDPOINT_ERRORS. At this line, the small replacement is:

        "connection timed out",
        "request timed out",
        "selected model is at capacity",
    

    The invariant is that these recognized terminal endpoint failures cannot pass the grading liveness gate. This suggestion is source-traced, not executed; please verify before adopting.

    Preservation / proposed acceptance: For each message, test the transcript and cleanup above: expect HTTP 502 and no liveness probe. Keep exit-1 reported failures classified with outcome-policy RETRY. Keep recovered retry → model output → unrelated kill gradable; HTTP-400 errors must remain non-endpoint errors, and the output-cap case must remain incomplete/max_output_tokens. The disabled opt-in must retain its existing behavior.

  • responses_api_agents/codex_agent/app.py line 1117: RISK — Apply conservative usage recovery to newly gradable failures

    Failure: With verify_after_agent_error=True, complete a model/tool call and kill Codex before turn.completed. Even if the native transcript contains cumulative token_count totals of input 20/output 8/total 28, the new scored response reports 0/0/0: the CLI parser starts at zero and this fallback runs only for reported_model_error. Reward profiling consumes those numeric usage values directly, understating failed-trial costs despite available measurements.

    Correction: Extend this condition while retaining its existing body:

            if (reported_model_error or graded_error is not None) and not completed:
    

    This reuses native cumulative usage, or marks usage unavailable when prior work is possible instead of publishing a fabricated zero. Downstream detail accesses already guard response_usage is None. Also update the new graded-failure integration assertions that currently require zero usage. Please verify this source-traced suggestion before adopting.

    Preservation / proposed acceptance: Combine the killed-agent setup with the existing native-usage fixture: expect failed status, null error, no classified outcome, and usage 20/8/28. With the same prior work but no usable native usage, expect usage=None; the new graded-failure environment integration should likewise expect unavailable usage. Preserve the existing provider-failure native/CLI/missing-usage assertions, known-zero no-work provider failures, completed-run total 22, and ordinary timeout/output-cap behavior. Tests were not executed.

  • responses_api_agents/codex_agent/app.py line 1105: RISK — Store captured observations before awaiting sandbox liveness

    Failure: Enable the opt-in, let a started failed run finish artifact collection, then block sandbox.status() or the true probe and close the session concurrently. This new await precedes assignment to state.observations. Session close removes the session files and cancels the activation, so the close response contains only the fallback interrupted gap rather than the already-collected conversation/tool evidence. This loses diagnostics for cancelled failed trials. The neighboring startup-stderr path deliberately stores observations before its await.

    Correction: Build and store a provisional failed/incomplete observation bundle before the grading probe; after the probe resolves, update the invocation to the final graded status. Preserve session-close ownership of cancellation—do not keep the sandbox open or extend close deadlines just to finish probing. Exact placement must also preserve native-usage recovery, so a move-only patch is not sufficiently certain to suggest here. The invariant is that cancellation during optional classification cannot discard captured observations.

    Proposed acceptance: Start a failed activation containing a partial tool/message transcript. Have status() signal entry and wait; close the session before releasing it. Expect activation cancellation, borrowed-sandbox disconnect without destruction, and close observations retaining the failed invocation and partial tool/message evidence. Preserve normal live-sandbox grading, stopped/unresponsive-sandbox execution errors, and the existing close-during-startup-stderr assertion that the failed invocation survives cancellation. These checks were not executed.

  • responses_api_agents/codex_agent/sandbox.py line 125: RISK — Keep optional archival within a separate capture sub-budget

    Failure: With sandbox_logs_dir enabled, a successful run becomes an execution failure if copying takes longer than session_close_timeout_seconds (default 60 seconds). The copy allows 120 seconds, but SandboxSession._finalize wraps this entire collector in the shorter close timeout. Cancellation escapes the copy's except Exception; events have not been read, capture records TimeoutError, and execution raises instead of returning the otherwise valid response. Verification is skipped, and close can delete the source without retrying capture. This contradicts the best-effort archival contract for large logs or slow mounts.

    Correction: Thread the actual capture budget/deadline from CodexSandboxSession.execute into the collector and archive operation. Enforce and catch a local archive timeout that expires before the enclosing deadline, reserving time for required event/metadata capture. The invariant is that optional copy failure or timeout cannot discard a readable transcript or change grading disposition. Budget allocation needs implementation verification; merely moving the copy after reads or catching outer cancellation still allows collection to abort before returning.

    Preservation / proposed acceptance: Set a short close budget and make only the archive exec wait past it while events and cleanup remain valid. Expect a completed response with 22 tokens, retained conversation observations, and successful close; archival may be omitted with a warning. Retain byte-for-byte copying in the existing archive test, no-op behavior without a logs/source directory, and successful response capture when copying returns nonzero or raises. Timeout/close must still preserve available partial logs only after confirmed termination; unconfirmed cleanup must continue to prevent capture and grading. Checks were not executed.

Review generated by codex. This was a full review. Request a full review with /review reset=true.

… errors

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
@gwarmstrong

Copy link
Copy Markdown
Contributor Author

/ok to test 4601f68

@gwarmstrong

gwarmstrong commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

All four findings reproduced against the code, and each now has a test that fails on the previous head.

  1. Capacity and request-timeout errors (BLOCKER). Added selected model is at capacity and request timed out to _MODEL_ENDPOINT_ERRORS (8ae8bf5). test_native_endpoint_failures_are_retried_not_graded covers both messages two ways: a runner killed after Codex reported the failure is an execution error (502) and the sandbox is never probed, and the exit-1 reported failure still keeps its classified cause and is retried by the outcome policy. Both messages are also added to the recovered-retry transcript in test_recovered_codex_0154_retries_then_an_agent_error_is_graded, which stays gradable.

  2. Usage of a graded failure. A failed run that verify_after_agent_error may grade now takes the same native-usage recovery as a reported provider failure (e18c8cb): Codex's native cumulative usage when present, otherwise None when there was prior work, never the CLI parser's zero. test_graded_failure_reports_native_usage_or_none checks 20/8/28 from the native transcript, and None without it. The graded-failure environment integration now expects unavailable usage.

  3. Observations before the liveness probe. The probe now runs after the observation bundle is stored (e18c8cb). If grading changes the status (a run that also hit its time limit becomes incomplete), the stored invocation is updated in place. Session close keeps ownership of cancellation. test_close_during_the_grading_probe_keeps_the_observations holds status(), closes the session, and checks the activation is cancelled, the borrowed sandbox is disconnected and not stopped, and the close observations keep the failed invocation with its message and tool call.

  4. Archive copy and the capture budget. The session-rollout copy now runs after events, runtime, the native snapshot and model attempts are read, and under its own timeout that ends one second before the capture budget does (SandboxSession's close timeout, passed in from execute and close) (4601f68). A timeout is logged and the copy skipped. test_a_slow_session_rollout_copy_does_not_cost_the_run sets a 3 s close budget and makes only the copy hang: the run returns completed with 22 tokens and its observations, and close succeeds. The byte-for-byte copy test and the no-op cases are unchanged; the ordering test now expects the copy last.

codex_agent: 465 passed (7 skipped); supervisor and sandbox-session units 87; pre-commit and detect-secrets clean.

@yaoyu-33

Copy link
Copy Markdown
Contributor

@gwarmstrong this branch now conflicts with main. Could you merge main in? We'll approve once it merges cleanly.

@yaoyu-33

Copy link
Copy Markdown
Contributor

@gwarmstrong the inline note is non-blocking; the only thing left is that the branch now conflicts with main after #5151, in responses_api_agents/codex_agent/sandbox_runner.py and tests/test_sandbox_runner.py. In the runner, #5151 added NEMO_GYM_SUPERVISOR to the default environment allowlist so the supervisor can find Codex's tools after they leave its process tree (sandbox_runner.py#L63-L64); please keep it in the non-inherit_env branch when you merge main.

yaoyu-33
yaoyu-33 previously approved these changes Oct 28, 2026

@yaoyu-33 yaoyu-33 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 4601f68; the inline note is non-blocking. Please merge main (conflict with #5151 in the Codex sandbox runner) before landing.

@yaoyu-33 yaoyu-33 added ready-to-merge PR is approved, current, and waiting for final CI or merge and removed needs-review PR is ready for code review and waiting on a reviewer labels Oct 28, 2026
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
@gwarmstrong

Copy link
Copy Markdown
Contributor Author

/ok to test 04ed490

@gwarmstrong

Copy link
Copy Markdown
Contributor Author

@yaoyu-33 thanks, main is merged in (04ed490). The only conflicts were with #5151 in sandbox_runner.py and its test. In the runner, NEMO_GYM_SUPERVISOR is now in the default environment allowlist, the one used when inherit_env is off; with inherit_env on, Codex already gets the whole task environment, which includes it. In the test file I kept this PR's inherit_env test and dropped test_lost_spawn_handle_requires_sandbox_teardown, which #5151 removed along with the Python supervisor.

Nothing else needed adapting: the Codex runner still runs as a standard-library Python runner under the bash supervisor. codex_agent: 463 passed (7 skipped; Python 3.8 and the pinned Codex CLI aren't installed here); test_process_supervisor.py and test_sandbox_session.py: 93 passed; pre-commit clean.

@gwarmstrong
gwarmstrong enabled auto-merge October 28, 2026 17:13
@gwarmstrong
gwarmstrong added this pull request to the merge queue Oct 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/40739984627

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 28, 2026
@gwarmstrong
gwarmstrong added this pull request to the merge queue Oct 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/40745253835

@github-actions

Copy link
Copy Markdown
Contributor

🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/40750164067

Merged via the queue into NVIDIA-NeMo:main with commit 7e1c403 Oct 28, 2026
47 of 50 checks passed
@gwarmstrong
gwarmstrong deleted the georgea/codex-agent-plain-exec branch October 28, 2026 19:37
@gwarmstrong

Copy link
Copy Markdown
Contributor Author

Smoke on this head (04ed490, with PR 6 66d4c71 and the Terminal-Bench-Science Codex profile, on main c155b77): Codex 0.154.0 in two Terminal-Bench-Science task sandboxes on OpenSandbox with verify_after_agent_error on, against a self-hosted DeepSeek V4.1 Flash replica, 30 minutes per trial.

geometric-pharmacophore-alignment, whose image has no WORKDIR, ran with Codex's working directory at / and was stopped at the cap and graded (0/7). ankle-mri-findings ran in /app and was graded (1/5). 221 model calls, all 200 apart from the one in flight at the cap. No agent error occurred in these two trials, so the verify-after-error path wasn't exercised; its unit tests cover it.

yaoyu-33 added a commit that referenced this pull request Oct 29, 2026
#5156 changed Codex's Python worker, which this branch replaces with the
bash runner and the Node launcher. Its worker options are ported:
- inherit_env: the launcher passes Codex the task's whole environment;
  the runner then keeps the task's NODE_OPTIONS too, as a plain codex
  exec in the task would see it.
- raise_oom_score_adj: runner setup raises the runner's own score, which
  the launcher, Codex and every tool inherit, and logs the change to the
  supervisor's output.log; the supervisor keeps the sandbox's score.
- A root workdir is accepted, as #5156 does: the POSIX overlap check
  skips "adapter path inside the workdir" when the workdir is /.
#5156's worker tests are ported to the runner and launcher, and its
root-workdir checks now run in POSIX sh. README keeps both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>

This branch was previously deployed

1 inactive deployment
public — 04ed4907 Deployed Oct 28, 2026 by copy-pr-bot[bot] via release / finalize / notify #7825
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:agent Agent harnesses and Responses API agent behavior feature New capabilities, enhancements, or enablement work ready-to-merge PR is approved, current, and waiting for final CI or merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants