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
2 changes: 1 addition & 1 deletion docs/codex_cli.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The following options are supported for customizing the behavior of the agent:
| Option | Description |
|------------------------------------|------------------------------------|
| `system_prompt` | Additional system prompt to append to default system prompt. |
| `model_config` | Codex model slug used to select the system prompt and tool set. Defaults to `None`, which derives the slug from the model used by the agent so Codex's prompt/tooling aligns with what's actually running. |
| `model_config` | Codex model slug used to select the system prompt and tool set. Defaults to `None`, which derives the slug from the model used by the agent so Codex's prompt/tooling aligns with what's actually running. The slug comes from the file named by `model_catalog_json` in `config_overrides` when set, else from the catalog of the installed Codex release. `model_catalog_json` must be an absolute path to a file in the sandbox. Give a plain path, alone or in quotes, with no backslashes, quote characters or control characters inside. For any other value, or a file that cannot be read, alignment warns and uses the release catalog. |
| `skills` | Additional [skills](https://inspect.aisi.org.uk/tools-standard.html#sec-skill) to make available to the agent. |
| `mcp_servers` | MCP servers (see [MCP Servers](#mcp-servers) below for details). |
| `bridged_tools` | Host-side Inspect tools to expose via MCP (see [Bridged Tools](#bridged-tools) below for details). |
Expand Down
11 changes: 10 additions & 1 deletion src/inspect_swe/_codex_cli/code_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@
_SKIP_KEYS = ("features", "model_catalog_json", "profile")


def config_override_sets(key: str, name: str) -> bool:
"""Whether a `config_overrides` key sets `name` or a key inside it.

Codex trims each `-c` key, and a dotted key sets a field of a table.
"""
key = key.strip()
return key == name or key.startswith(f"{name}.")


def check_codex_code_mode_bridged_tools(
codex_model: str,
release_catalog: dict[str, Any] | None,
Expand All @@ -43,7 +52,7 @@ def check_codex_code_mode_bridged_tools(
bridged_tools: The agent's bridged tool specs.
"""
if release_catalog is None or any(
key.strip() == skip or key.strip().startswith(f"{skip}.")
config_override_sets(key, skip)
for key in config_overrides or {}
for skip in _SKIP_KEYS
):
Expand Down
98 changes: 86 additions & 12 deletions src/inspect_swe/_codex_cli/codex_cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import json
import mimetypes
import re
import shlex
import string
from logging import getLogger
from pathlib import Path, PurePosixPath
from textwrap import dedent
Expand Down Expand Up @@ -31,7 +34,12 @@
install_skills,
read_skills,
)
from inspect_ai.util import SandboxEnvironment, checkpointer, store
from inspect_ai.util import (
OutputLimitExceededError,
SandboxEnvironment,
checkpointer,
store,
)
from inspect_ai.util import sandbox as sandbox_env
from inspect_ai.util._sandbox import ExecRemoteAwaitableOptions
from typing_extensions import Unpack
Expand All @@ -57,7 +65,7 @@
codex_cli_binary_source,
codex_version_models_catalog,
)
from .code_mode import check_codex_code_mode_bridged_tools
from .code_mode import check_codex_code_mode_bridged_tools, config_override_sets
from .config import (
MCP_STARTUP_TIMEOUT_SEC,
CodexApprovalPolicy,
Expand Down Expand Up @@ -128,7 +136,7 @@ def codex_cli(
approve_static_mcp_tools: bool = False,
**deprecated_args: Unpack[CodexDeprecatedArgs],
) -> Agent:
"""Codex CLI.
r"""Codex CLI.

Agent that uses OpenAI [Codex CLI](https://github.com/openai/codex) running in a sandbox.

Expand All @@ -145,8 +153,11 @@ def codex_cli(
system_prompt: Additional system prompt to append to default system prompt.
model_config: Codex model slug used to select the system prompt and tool
set. Defaults to `None`, which derives the slug from the real model so
Codex's prompt/tooling aligns with what's actually running. Pass an
explicit slug to override.
Codex's prompt/tooling aligns with what's actually running. The
slug comes from the `model_catalog_json` file in `config_overrides`
when set (a plain absolute path in the sandbox, optionally
quoted, with no escapes), else from the installed release's
catalog. Pass an explicit slug to override.
skills: Additional [skills](https://inspect.aisi.org.uk/tools-standard.html#sec-skill) to make available to the agent.
mcp_servers: MCP servers to make available to the agent.
bridged_tools: Host-side Inspect tools to expose to the agent via MCP.
Expand Down Expand Up @@ -508,6 +519,7 @@ async def probe_codex_version(binary: str) -> str | None:
# align Codex's `--model` slug to the real bridged model, and fail
# before launch if code mode would deny every bridged tool call
codex_model = await _resolve_codex_model_checked(
sbox,
model,
model_config,
codex_version,
Expand Down Expand Up @@ -863,6 +875,7 @@ async def _stage_prompt_images(


async def _resolve_codex_model_checked(
sandbox: SandboxEnvironment,
model: str | None,
model_config: str | None,
codex_version: str | None,
Expand All @@ -872,20 +885,81 @@ async def _resolve_codex_model_checked(
"""Resolve the `--model` slug, then run the code-mode check on bridged tools.

The release catalog is fetched once and shared by both, so an offline run
pays for one failed fetch, not two.
pays for one failed fetch, not two. Alignment chooses from the caller's
`model_catalog_json` instead when it is set and readable.
"""
version_catalog = await codex_version_models_catalog(codex_version)
codex_model = await resolve_codex_model(model, model_config, version_catalog)
custom_catalog = (
await _read_model_catalog_json(sandbox, config_overrides)
if model_config is None
else None
)
codex_model = await resolve_codex_model(
model,
model_config,
custom_catalog if custom_catalog is not None else version_catalog,
)
check_codex_code_mode_bridged_tools(
codex_model, version_catalog, config_overrides, bridged_tools
)
return codex_model


# a path alone or in one pair of quotes, with no quote, backslash or control
# character: Codex reads it as TOML or as the raw text with quotes trimmed,
# and either way names the same file
_PLAIN_PATH = re.compile(r"""(["']?)([^"'\\\x00-\x1f\x7f]*)\1""")


async def _read_model_catalog_json(
sandbox: SandboxEnvironment, config_overrides: dict[str, str] | None
) -> dict[str, Any] | None:
"""The catalog file named by `model_catalog_json`, or `None`.

Codex uses that file in place of its own catalog. Only a plain absolute
path, optionally quoted, to a JSON object with a `models` list is read;
when it is set but cannot be read, a warning names it and alignment uses
the release catalog.
"""
overrides = config_overrides or {}
keys = [key for key in overrides if config_override_sets(key, "model_catalog_json")]
if not keys:
return None
# the last -c pair wins, and a dotted key names no file; str() the value
# as the -c pair does, so a Path names its file
key = keys[-1]
value = str(overrides[key]) if key.strip() == "model_catalog_json" else ""
value = value.strip(string.whitespace)
Comment thread
ransomr marked this conversation as resolved.
plain = _PLAIN_PATH.fullmatch(value)
# Codex trims whitespace from a bare path
if plain is None or (not plain[1] and plain[2] != plain[2].strip()):
logger.warning(
f"codex_cli could not read model_catalog_json {value!r}: it reads "
"only a plain absolute path, optionally quoted, so it chooses "
"--model from the Codex release catalog."
)
return None
path = plain[2]
catalog = None
if PurePosixPath(path).is_absolute():
try:
catalog = json.loads(await sandbox.read_file(path))
except (OSError, UnicodeDecodeError, ValueError, OutputLimitExceededError):
Comment thread
ransomr marked this conversation as resolved.
pass
if not isinstance(catalog, dict) or not isinstance(catalog.get("models"), list):
logger.warning(
f"codex_cli could not read model_catalog_json {path!r} in the "
"sandbox (it reads only an absolute path to a JSON object with a "
"models list), so it chooses --model from the Codex release catalog."
)
return None
return catalog


async def resolve_codex_model(
model: str | None,
model_config: str | None,
version_catalog: dict[str, Any] | None,
catalog: dict[str, Any] | None,
) -> str:
"""Resolve the Codex `--model` slug aligned to the real bridged model.

Expand All @@ -896,9 +970,9 @@ async def resolve_codex_model(
`service_model_name()` rather than the registry name — so a custom `otter`
provider reporting `gpt-5.5` aligns to that catalog entry. "latest"/codename
models (per the provider's `is_latest()`) align to the latest catalog profile
rather than Codex's generic fallback. `version_catalog` is the installed
release's `models.json` (`None` when unknown, which aligns against the
bundled snapshot).
rather than Codex's generic fallback. `catalog` is the caller's
`model_catalog_json` or the installed release's `models.json` (`None` when
unknown, which aligns against the bundled snapshot).
"""
resolved_model = get_model(model)
real_model = ModelName(resolved_model)
Expand All @@ -911,7 +985,7 @@ async def resolve_codex_model(
resolution = resolve_codex_model_slug(
model_name,
api=real_api,
catalog=codex_alignment_catalog(version_catalog),
catalog=codex_alignment_catalog(catalog),
override=model_config,
is_latest=is_latest_openai_model(api),
)
Expand Down
18 changes: 15 additions & 3 deletions src/inspect_swe/_codex_cli/model_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,8 @@ def resolve_codex_model_slug(
(gpt-5.4+), in which case it returns ``_GENERIC_FALLBACK_SLUG`` to force
Codex's generic prompt (no ``apply_patch``, no ``tool_search``) and avoid a
``tool_search`` 400. (Catalog ``supports_search_tool`` reflects Codex's own
backend, not the public Responses API the bridge uses.)
backend, not the public Responses API the bridge uses.) It raises
``ValueError`` when a catalog entry matches that slug.
- An OpenAI model *not* in the catalog returns the latest catalog slug —
getting the full coding prompt + ``apply_patch`` + ``tool_search`` — when it
supports ``tool_search`` (gpt-5.4+, see ``_supports_tool_search``) or is a
Expand All @@ -229,8 +230,9 @@ def resolve_codex_model_slug(
Args:
model_name: The real model's name without provider prefix (e.g. ``gpt-5``).
api: The real model's provider/api (e.g. ``openai``).
catalog: The version-matched Codex model catalog, or ``None`` if
unavailable (in which case we defer to Codex's own bundled catalog).
catalog: The Codex model catalog (the caller's ``model_catalog_json``
or the version-matched one), or ``None`` if unavailable (in which
case we defer to Codex's own bundled catalog).
override: Explicit ``model_config`` value, or ``None`` to derive.
is_latest: Whether the provider flags this as a "latest"/codename model
(see ``is_latest_openai_model``) — a pre-deployment frontier model, so
Expand Down Expand Up @@ -269,6 +271,16 @@ def resolve_codex_model_slug(
if matched.get("supports_search_tool") and not tool_search_ok:
# catalog would bind tool_search, but this real model predates the
# public-API boundary → force generic fallback to avoid a 400.
captor = _matched_entry(_GENERIC_FALLBACK_SLUG, models)
if captor is not None:
# a custom catalog can name an entry that the sentinel matches
raise ValueError(
f"codex_cli runs '{model_name}' with Codex's generic prompt "
"(it predates tool_search) through the slug "
f"'{_GENERIC_FALLBACK_SLUG}', but the Codex catalog entry "
f"'{captor['slug']}' matches that slug. Rename the entry or "
"set model_config."
)
return CodexModelResolution(
slug=_GENERIC_FALLBACK_SLUG,
reason=(
Expand Down
Loading
Loading