Files
2026-07-13 13:12:00 +08:00

2394 lines
101 KiB
Python

"""CodexExecutor: run agents through the Codex App Server.
This executor keeps one long-lived ``codex app-server`` subprocess per
Omnigent session, persists the Codex thread across turns, and exposes
Omnigent tools to Codex as App Server ``dynamicTools``.
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import os
import re
import shutil
import tempfile
import time
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol, TypeAlias
from omnigent.llms._usage_observer import notify_from_dict as _notify_usage_from_dict
from omnigent.reasoning_effort import CODEX_EFFORTS, validate_effort
from omnigent.runner.identity import OMNIGENT_SESSION_ENV_VAR
from omnigent.spec.types import RetryPolicy
from . import _proc
from ._subprocess_lifecycle import close_subprocess_transport
from .databricks_executor import (
_read_databrickscfg,
_read_databrickscfg_host,
)
from .datamodel import OSEnvSandboxSpec, OSEnvSpec
from .executor import (
Executor,
ExecutorConfig,
ExecutorError,
ExecutorEvent,
Message,
ReasoningChunk,
TextChunk,
ToolArgs,
ToolCallComplete,
ToolCallRequest,
ToolCallStatus,
ToolSpec,
TurnComplete,
classify_tool_result,
)
logger = logging.getLogger(__name__)
# Default auth-token refresh cadence (ms) for the vendor-neutral gateway
# transport when ``HARNESS_CODEX_GATEWAY_AUTH_REFRESH_INTERVAL_MS`` is unset.
# Not Databricks-specific: the same fallback applies to any gateway producer
# (Databricks AI gateway or a generic key/gateway provider).
_GATEWAY_AUTH_REFRESH_MS = 900_000
# ---------------------------------------------------------------------------
# Type aliases for JSON-shaped Codex App Server boundaries
# ---------------------------------------------------------------------------
# Each line of the Codex App Server JSONL protocol: envelope with optional
# ``id`` / ``method`` / ``params`` / ``result`` / ``error`` keys. The inner
# field shapes vary per method and are the Codex CLI's contract, not ours.
CodexMessage: TypeAlias = dict[str, Any] # type: ignore[explicit-any]
# ``params`` payload inside a Codex message — free-form JSON keyed by string.
CodexParams: TypeAlias = dict[str, Any] # type: ignore[explicit-any]
# Normalised tool result dict. Most handlers return ``{"result": ...}`` or
# ``{"error": ...}``; the inner value can be any JSON-shaped payload.
CodexToolResult: TypeAlias = dict[str, Any] # type: ignore[explicit-any]
# Content passed to ``enqueue_message`` — arbitrary user-supplied payload.
CodexEnqueuedContent: TypeAlias = Any # type: ignore[explicit-any]
# Tool-server callback provided by ``Session._wire_sdk_executor``. Takes a
# tool name + args dict and returns either a result dict directly or a
# coroutine yielding one.
CodexToolExecutor: TypeAlias = Callable[
[str, ToolArgs],
Awaitable[CodexToolResult] | CodexToolResult,
]
# When the app-server is silent for this long we emit a warning,
# but keep waiting — a long-running tool or model call can legitimately
# block events far longer than any fixed deadline.
_TURN_EVENT_WARN_SECONDS = 600.0
_TURN_COMPLETED_DRAIN_SECONDS = 1.0
# Wall-clock budget for the ``codex --version`` probe. A broken codex
# build that blocks (e.g. on stdin) must not stall session startup — on
# timeout the probe kills the process and reports the version as unknown.
_CODEX_VERSION_PROBE_TIMEOUT_SECONDS = 5.0
_STDERR_CHUNK_LIMIT = 65536
_STREAM_READ_CHUNK_SIZE = 65536
_OPENAI_CODEX_DEFAULT_MODEL = "gpt-5.4-mini"
# Databricks-specific default model for the Databricks-profile-derivation
# gateway path (no gateway base URL supplied directly). The neutral
# generic-provider gateway path never uses this — it requires the Omnigent producer
# to resolve a concrete model. Used only when constructing the codex config
# from ~/.databrickscfg credentials with no spec/override model.
_DATABRICKS_CODEX_DEFAULT_MODEL = "databricks-gpt-5-5"
# Files symlinked from the real CODEX_HOME into the per-session temp home.
# Symlinks (not copies) so credential refreshes in the real home propagate
# to running sessions without any action from Omnigent.
_CODEX_HOME_SYMLINK_FILES = ("auth.json",)
# Files copied (not symlinked) from the real CODEX_HOME into the per-session
# temp home. config.toml is intentionally copied so that an in-TUI ``/model``
# command writes only to the session's own private copy and never touches the
# shared ``~/.codex/config.toml``. This keeps model selection and cost-policy
# enforcement isolated between concurrent sessions.
_CODEX_HOME_COPY_FILES = ("config.toml",)
# Environment variables explicitly excluded from the codex subprocess even
# when their prefix is in the allowlist. ``OPENAI_API_KEY`` is stripped so
# the codex CLI uses its subscription auth (``auth.json``) rather than a
# developer API key that would charge separately.
_CODEX_ENV_DENY_EXACT: frozenset[str] = frozenset({"OPENAI_API_KEY"})
def _extract_codex_last_turn_usage(params: object) -> dict[str, int] | None:
"""Map a ``thread/tokenUsage/updated`` payload's ``last`` breakdown
onto the wire shape that :class:`TurnComplete` consumes.
Codex's ``inputTokens`` is INCLUSIVE of cached tokens (codex-rs:
``non_cached_input = input_tokens - cached_input_tokens``), and
:func:`compute_llm_cost` expects ``input_tokens`` to be the *non-cached*
portion with cache reads priced separately. So split ``cachedInputTokens``
out into ``cache_read_input_tokens`` and keep only the remainder in
``input_tokens`` — otherwise cached tokens are billed at the full input
rate. Mirrors the codex-native forwarder split.
"""
if not isinstance(params, dict):
return None
token_usage = params.get("tokenUsage")
if not isinstance(token_usage, dict):
return None
last = token_usage.get("last")
if not isinstance(last, dict):
return None
input_total = int(last.get("inputTokens") or 0)
# Clamp so a malformed cached > total never makes input_tokens negative.
cached = min(int(last.get("cachedInputTokens") or 0), input_total)
usage = {
"input_tokens": input_total - cached, # non-cached portion
"output_tokens": int(last.get("outputTokens") or 0),
"total_tokens": int(last.get("totalTokens") or 0),
}
if cached:
usage["cache_read_input_tokens"] = cached
return usage
def _format_codex_error_params(params: object) -> str:
"""
Format a Codex App Server JSON-RPC error frame's ``params`` dict
into a single-line diagnostic string.
JSON-RPC error frames carry ``code``, ``message``, and ``data``
fields. The codex CLI's app-server populates them inconsistently
— some failure modes leave ``message`` empty and only set
``code`` + ``data``, which used to surface to the user as the
bare fallback string ``"Codex App Server error"`` with no clue
why. This helper emits whatever populated fields are present so
config mismatches (Claude model on a codex harness, bad
profile, provider HTTP errors, etc.) leave a useful trail.
:param params: The ``params`` value from the JSON-RPC error
frame. Expected to be a dict, but defensive against
non-dict shapes (codex's protocol allows ``None`` or
scalars in some edge paths).
:returns: A single-line human-readable summary, e.g.
``"Bad model 'foo'; code=invalid_argument; data='details'"``.
Falls back to ``"Codex App Server error (no params)"`` only
when ``params`` is empty / not a dict — never returns a
message that makes the user re-grep the codebase to find
out what failed.
"""
if not isinstance(params, dict) or not params:
return "Codex App Server error (no params)"
parts: list[str] = []
message = params.get("message")
if isinstance(message, str) and message.strip():
parts.append(message.strip())
# The app server's ``error`` events nest the actual upstream
# failure under ``params["error"]`` (a dict with its own
# ``message`` / ``codexErrorInfo`` / ``additionalDetails``
# fields). The inner message is sometimes a stringified JSON
# blob from the provider — try to parse it so the human-readable
# ``message`` field surfaces; otherwise fall back to the raw
# string. This is the path that turns a bare "Codex App Server
# error" into something like "Responses API passthrough is not
# supported for model X".
inner = params.get("error")
if isinstance(inner, dict):
inner_message = inner.get("message")
if isinstance(inner_message, str) and inner_message.strip():
parts.append(_unwrap_provider_error_json(inner_message.strip()))
inner_info = inner.get("codexErrorInfo")
if isinstance(inner_info, str) and inner_info.strip() and inner_info != "other":
parts.append(f"codexErrorInfo={inner_info}")
inner_details = inner.get("additionalDetails")
if inner_details:
parts.append(f"details={inner_details!r}")
code = params.get("code")
if code is not None:
parts.append(f"code={code}")
data = params.get("data")
if data is not None and data != "":
parts.append(f"data={data!r}")
if not parts:
# No standard JSON-RPC fields populated; dump the raw params
# so the user can still see what codex sent.
return f"Codex App Server error: raw_params={params!r}"
return "; ".join(parts)
def _unwrap_provider_error_json(text: str) -> str:
"""
Try to JSON-parse *text* and return its ``message`` field.
Codex relays provider HTTP errors as a stringified JSON blob
(e.g. ``'{"error_code":"BAD_REQUEST","message":"..."}'`` from
Databricks gateway). Returning the raw string is technically
accurate but visually noisy; extracting the human-readable
``message`` field gives the user the actionable line directly.
:param text: The candidate string. Expected to be either a JSON
object with a ``message`` field, or a plain error string
(e.g. ``"connection refused"``).
:returns: The extracted ``message`` field if *text* parses as
JSON and contains one; otherwise *text* unchanged.
"""
import json
try:
parsed = json.loads(text)
except (json.JSONDecodeError, ValueError):
return text
if isinstance(parsed, dict):
# Provider-style: ``{"error_code": ..., "message": "..."}``.
provider_message = parsed.get("message")
if isinstance(provider_message, str) and provider_message.strip():
error_code = parsed.get("error_code") or parsed.get("code")
if error_code:
return f"{provider_message.strip()} (error_code={error_code})"
return provider_message.strip()
return text
class _Process(Protocol):
"""Subset of ``asyncio.subprocess.Process`` we touch in process-tree helpers.
Declaring a Protocol lets these helpers accept both real processes and
test-supplied fakes without falling back on ``getattr(..., "<literal>")``
to dodge mypy's attr-defined check. Properties (not bare attributes) to
match ``asyncio.subprocess.Process`` where these are read-only.
"""
@property
def pid(self) -> int: ...
@property
def returncode(self) -> int | None: ...
def terminate(self) -> None: ...
def kill(self) -> None: ...
def _terminate_process_tree(process: _Process | None) -> None:
_proc.terminate_tree(process)
def _kill_process_tree(process: _Process | None) -> None:
_proc.kill_tree(process)
def _find_codex_cli() -> str | None:
return shutil.which("codex")
async def _codex_cli_version(codex_path: str) -> tuple[int, int, int] | None:
"""
Return the codex CLI version as a ``(major, minor, patch)`` tuple.
Runs ``codex --version`` and parses the numeric core of its
``codex-cli X.Y.Z`` output. A pre-release suffix (e.g.
``0.132.0-alpha.1``) is ignored — only ``X.Y.Z`` is parsed — so an
alpha of a supported release still compares as that release.
:param codex_path: Path to the codex CLI, e.g.
``"/usr/local/bin/codex"``.
:returns: The parsed version, e.g. ``(0, 136, 0)``, or ``None`` if
the command cannot be run, times out, or its output has no
``X.Y.Z`` token (caller treats ``None`` as "version unknown", not
"too old").
"""
try:
proc = await _create_subprocess_exec(
codex_path,
"--version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
except OSError:
return None
try:
stdout, _ = await asyncio.wait_for(
proc.communicate(), timeout=_CODEX_VERSION_PROBE_TIMEOUT_SECONDS
)
except asyncio.TimeoutError:
# A hung `codex --version` must not block session startup: kill it
# and report the version as unknown (the caller proceeds).
with suppress(ProcessLookupError):
proc.kill()
with suppress(Exception):
await proc.wait()
return None
match = re.search(r"(\d+)\.(\d+)\.(\d+)", stdout.decode("utf-8", errors="replace"))
if match is None:
return None
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
async def _create_subprocess_exec(*args: Any, **kwargs: Any) -> asyncio.subprocess.Process:
"""
Indirection point for ``asyncio.create_subprocess_exec``.
Exists so tests can stub the subprocess creation without
patching ``asyncio.create_subprocess_exec`` globally (patching
``omnigent.inner.codex_executor.asyncio.create_subprocess_exec``
walks the dotted path into the real ``asyncio`` module
singleton and leaks the mock into every other test in the
process).
:param args: Positional argv components forwarded to
``asyncio.create_subprocess_exec``.
:param kwargs: Keyword args (``stdin``, ``stdout``, ``stderr``,
``env``, ``cwd``, ...) forwarded as-is.
:returns: The spawned subprocess handle.
"""
return await asyncio.create_subprocess_exec(*args, **kwargs)
def _clean_codex_env(extra_allow: Iterable[str] = ()) -> dict[str, str]:
"""
Build a filtered copy of ``os.environ`` for the codex subprocess.
Uses a prefix allowlist so only known-safe categories pass through
(proxy settings, locale, OpenAI retry knobs, etc.). Keys in
:data:`_CODEX_ENV_DENY_EXACT` are excluded even when their prefix
matches; ``OPENAI_API_KEY`` is stripped so the codex CLI falls
back to subscription auth (``auth.json``) rather than a developer
API key that would charge separately.
:returns: Filtered environment dict.
"""
env: dict[str, str] = {}
allow_prefixes = (
"OPENAI_",
"HTTP_",
"HTTPS_",
"ALL_PROXY",
"NO_PROXY",
"SSL_",
"REQUESTS_",
"CODEX_HOME",
"XDG_",
"LANG",
"LC_",
)
allow_exact = {
"HOME",
"PATH",
"TERM",
"TMPDIR",
"TMP",
"TEMP",
"PYTHONUTF8",
"DATABRICKS_BEARER", # explicit CI/integration bearer used by auth.command
"DATABRICKS_CODEX_TOKEN", # env_key referenced by ~/.codex/config.toml's DB provider
OMNIGENT_SESSION_ENV_VAR, # "inside Omnigent" marker (CLAUDE_CODE/CODEX analog)
} | set(extra_allow)
for key, value in os.environ.items():
if key in _CODEX_ENV_DENY_EXACT:
continue
if key in allow_exact or key.startswith(allow_prefixes):
env[key] = value
return env
def _declared_passthrough(os_env: OSEnvSpec | None) -> tuple[str, ...]:
"""Env-var names an agent declared for tool passthrough.
Lives on ``os_env.sandbox.env_passthrough`` (an
:class:`OSEnvSandboxSpec` field), not on ``OSEnvSpec`` directly.
Returns an empty tuple when any link in that chain is absent.
"""
if os_env is not None and os_env.sandbox is not None and os_env.sandbox.env_passthrough:
return tuple(os_env.sandbox.env_passthrough)
return ()
def codex_skill_sources(bundle_dir: Path | None, home: Path) -> list[Path]:
"""
Build the ordered Codex skill-source list: bundle skills, then host skills.
The single source of truth for *where* Codex skills come from, shared
by :func:`populate_codex_skills_from_bundle` (which symlinks them into
``$CODEX_HOME/skills/``) and the slash-command menu's ``codex_host_skills``
provider — so the linked set and the menu cannot drift on which roots
are scanned. Priority order: the agent's own ``<bundle>/skills/`` before
host-installed ``<home>/.codex/skills/`` (a bundled skill shadows a host
skill of the same name). Only existing directories are returned.
:param bundle_dir: Materialized agent-bundle root, or ``None``.
:param home: The user home directory (``Path.home()``); injected so
tests and the menu provider can pin it.
:returns: Existing skill-dir roots in priority order.
"""
sources: list[Path] = []
if bundle_dir is not None and (bundle_dir / "skills").is_dir():
sources.append(bundle_dir / "skills")
host = home / ".codex" / "skills"
if host.is_dir():
sources.append(host)
return sources
def select_codex_skill_dirs(
skills_filter: str | list[str],
sources: list[Path],
) -> dict[str, Path]:
"""
Resolve skill name → directory for a Codex skill source list.
The single source of truth for "which skills does this Codex session
expose", shared by :func:`_populate_codex_skills` (which symlinks the
result into ``$CODEX_HOME/skills/``) and the slash-command menu's
Codex skill source — so the menu and the actually-linked set cannot
diverge.
:param skills_filter: ``"all"`` selects every skill found in
*sources*; ``"none"`` selects nothing; a ``list[str]`` selects
only the named skills present in some source. Names not present
are silently skipped.
:param sources: Ordered skill-dir roots (each containing
``<name>/SKILL.md`` subdirs). The first source that contains a
given skill name wins.
:returns: Ordered mapping of selected skill name → absolute dir.
"""
if skills_filter == "none":
return {}
available: dict[str, Path] = {}
for source in sources:
if not source.is_dir():
continue
try:
children = sorted(source.iterdir())
except OSError as exc:
# An unreadable source (permission denied, races) must not abort
# skill discovery / session startup — skip it and continue.
logger.warning("could not list codex skill source %s (%s); skipping", source, exc)
continue
for child in children:
if not child.is_dir():
continue
if not (child / "SKILL.md").is_file():
continue
available.setdefault(child.name, child)
if skills_filter == "all":
names = list(available.keys())
elif isinstance(skills_filter, list):
names = [n for n in skills_filter if n in available]
else:
return {}
return {n: available[n] for n in names}
def _populate_codex_skills(
target_dir: Path,
skills_filter: str | list[str],
sources: list[Path],
) -> None:
"""
Populate *target_dir* with symlinks to skill directories.
Codex auto-discovers skills under ``$CODEX_HOME/skills/<name>/``.
Our executor already overrides ``CODEX_HOME`` to a per-conversation
temp directory (so the user's real ``~/.codex/`` is never touched),
which means by default Codex sees zero skills. This helper populates
the temp ``skills/`` subdir based on the agent spec's ``skills:``
field, sourcing skill directories from ``sources`` (typically the
user's ``~/.codex/skills/`` plus any ``<bundle>/skills/``). Skill
selection is delegated to :func:`select_codex_skill_dirs`.
:param target_dir: ``<temp_codex_home>/skills/`` — the directory
Codex will scan. Created if it doesn't exist (unless
``skills_filter == "none"``, in which case the directory is
intentionally left absent so Codex sees nothing).
:param skills_filter: ``"all"`` exposes every skill found in
*sources*; ``"none"`` exposes none; ``list[str]`` exposes only
the named skills. Names not present in any source are silently
skipped — matches the SDK semantics where a missing host skill
produces no error, just no exposure.
:param sources: Ordered list of directories to scan. The first
source that contains a given skill name wins (so callers should
list bundled skills before host skills if they want bundle
overrides, or vice versa).
"""
if skills_filter == "none":
return
target_dir.mkdir(parents=True, exist_ok=True)
selected = select_codex_skill_dirs(skills_filter, sources)
for name, skill_dir in selected.items():
link_path = target_dir / name
if link_path.exists() or link_path.is_symlink():
continue
try:
# Resolve to absolute so the symlink doesn't break when
# the source was a relative path (relative symlinks resolve
# against the link's parent, not the original cwd).
link_path.symlink_to(skill_dir.resolve())
except OSError as exc:
# Filesystems without symlink support (e.g. some Windows
# configs) — fall back to a copy. Don't crash the harness
# boot over a skill-discovery convenience.
logger.warning(
"could not symlink skill %r into %s (%s); copying instead",
name,
target_dir,
exc,
)
try:
shutil.copytree(skill_dir, link_path)
except OSError as copy_exc:
# Copy fallback can also fail (unreadable source, race) — skip
# this one skill rather than abort the whole session boot.
logger.warning(
"could not copy skill %r into %s (%s); skipping",
name,
target_dir,
copy_exc,
)
def populate_codex_skills_from_bundle(
codex_home: Path,
bundle_dir: Path | None,
skills_filter: str | list[str],
) -> None:
"""
Populate a CODEX_HOME's ``skills/`` from a bundle + host skills.
Shared by the wrapped ``codex`` executor and the ``codex-native``
launch path so both expose the same skill surface. Builds the source
list in priority order — the agent's own ``<bundle>/skills/`` before
host-installed ``~/.codex/skills/`` (so a bundled skill shadows a
host skill of the same name) — and delegates to
:func:`_populate_codex_skills`, which honours ``skills_filter``
(``"all"`` / ``"none"`` / list of names).
:param codex_home: The CODEX_HOME whose ``skills/`` subdir Codex
scans, e.g. a per-conversation temp dir or the per-bridge native
home. ``<codex_home>/skills`` is created unless the filter is
``"none"``.
:param bundle_dir: Materialized agent-bundle root, or ``None`` when
the launch has no bundle. Its ``skills/`` subdir is used as the
first (highest-priority) source when present.
:param skills_filter: The spec's ``skills_filter``: ``"all"`` /
``"none"`` / a list of skill names.
:returns: None.
"""
skill_sources = codex_skill_sources(bundle_dir, Path.home())
_populate_codex_skills(codex_home / "skills", skills_filter, skill_sources)
def _is_omnigent_private_codex_home(path: Path) -> bool:
"""
Return whether *path* is an Omnigent-created private ``CODEX_HOME``.
Omnigent launches Codex with private homes for session state
isolation. Those homes are not the user's source of truth for auth;
nested launches must not treat them as the real login directory.
:param path: Candidate ``CODEX_HOME`` path, e.g.
``"/home/user/.omnigent/codex-native/<hash>/codex-home"``.
:returns: ``True`` when *path* matches a native bridge home or the
wrapped executor's temporary ``omnigent-codex-home-*`` naming.
"""
expanded = path.expanduser()
parts = expanded.parts
if (
expanded.name == "codex-home"
and len(parts) >= 4
and parts[-3] == "codex-native"
and parts[-4] == ".omnigent"
):
return True
return expanded.name.startswith("omnigent-codex-home-")
def _private_codex_home_config_source(path: Path) -> Path | None:
"""
Infer the original config source from a private Codex home.
A parent Omnigent launch bridges ``auth.json`` and ``config.toml`` into
its private home as symlinks. If a nested launch inherits that private
``CODEX_HOME``, those symlink targets are the only durable record of a
custom parent source.
:param path: Private ``CODEX_HOME`` path, e.g.
``"/home/user/.omnigent/codex-native/<hash>/codex-home"``.
:returns: The shared parent directory of bridged config symlink targets,
or ``None`` if the private home has no usable source symlink.
"""
source_dirs: set[Path] = set()
for filename in _CODEX_HOME_SYMLINK_FILES:
config_file = path / filename
if not config_file.is_symlink():
continue
with suppress(OSError):
source_dirs.add(config_file.resolve().parent)
if len(source_dirs) == 1:
return next(iter(source_dirs))
return None
def _resolve_codex_home_config_source(source_dir: Path, home_codex_home: Path) -> Path:
"""
Resolve the single Codex home to read auth/config from.
User-supplied ``CODEX_HOME`` remains authoritative, except when it
points at an Omnigent private home from a parent session. In that
nested case, the private home is session state, not the login source,
so the user's normal ``~/.codex`` directory is the intended source.
:param source_dir: Primary candidate from ``CODEX_HOME`` or
``home_codex_home``, e.g. ``Path("/custom/codex")``.
:param home_codex_home: The user's default Codex home, e.g.
``Path.home() / ".codex"``.
:returns: Exactly one source directory to use for both
``auth.json`` and ``config.toml``.
"""
if source_dir != home_codex_home and _is_omnigent_private_codex_home(source_dir):
private_source = _private_codex_home_config_source(source_dir)
if private_source is not None:
return private_source
return home_codex_home
return source_dir
def _codex_home_config_source_from_env() -> Path:
"""
Return the Codex home whose auth/config should be bridged.
Codex stores subscription login state in ``CODEX_HOME``. Omnigent
launches Codex with private per-session homes for isolation, then bridges
only auth/config from the user's source home. Nested Omnigent processes
can inherit a parent private home, so this resolver maps that specific
inherited session-state home back to the user's default ``~/.codex``.
:returns: Host Codex home to read ``auth.json`` and ``config.toml`` from,
e.g. ``Path.home() / ".codex"`` or an explicit user ``CODEX_HOME``.
"""
home_codex_home = Path.home() / ".codex"
return _resolve_codex_home_config_source(
Path(os.environ.get("CODEX_HOME") or str(home_codex_home)),
home_codex_home,
)
def _populate_codex_home_config(target_dir: Path, source_dir: Path) -> None:
"""
Bridge user config files from the real ``CODEX_HOME`` into the temp one.
The executor overrides ``CODEX_HOME`` to a per-conversation temp
directory so session data (conversation history, etc.) stays isolated
from the user's ``~/.codex/``. However, the codex CLI also reads
authentication tokens (``auth.json``) and provider configuration
(``config.toml``) from ``$CODEX_HOME``. This helper bridges those
files into the temp directory:
- ``auth.json`` is **symlinked** so OAuth token refreshes written to
the real home propagate to running sessions without delay.
- ``config.toml`` is **copied** so an in-TUI ``/model`` command writes
only to the session's own private copy and never mutates the shared
``~/.codex/config.toml``. This keeps model selection and cost-policy
enforcement isolated between concurrent sessions.
:param target_dir: The per-conversation temp ``CODEX_HOME``
directory. Must already exist.
:param source_dir: The primary ``CODEX_HOME`` directory
(typically ``$CODEX_HOME`` or ``~/.codex``). Missing files are
skipped.
"""
if not source_dir.is_dir():
return
for filename in _CODEX_HOME_SYMLINK_FILES:
source_file = source_dir / filename
if not source_file.is_file():
continue
link_path = target_dir / filename
if link_path.exists() or link_path.is_symlink():
continue
try:
link_path.symlink_to(source_file)
except OSError as exc:
logger.warning(
"could not symlink %r into %s (%s); copying instead",
filename,
target_dir,
exc,
)
shutil.copy2(source_file, link_path)
for filename in _CODEX_HOME_COPY_FILES:
source_file = source_dir / filename
if not source_file.is_file():
continue
dest_path = target_dir / filename
if dest_path.exists() or dest_path.is_symlink():
continue
shutil.copy2(source_file, dest_path)
def _databricks_codex_base_url(host: str) -> str:
"""Return the Unity AI Gateway Codex Responses base URL for *host*."""
return f"{host.rstrip('/')}/ai-gateway/codex/v1"
def _databricks_codex_auth_command(host: str, profile: str | None = None) -> str:
"""Return the legacy Databricks CLI auth helper command for Codex.
:param host: Databricks workspace host, e.g.
``"https://example.databricks.com"``.
:param profile: Optional ``~/.databrickscfg`` profile name, e.g.
``"oss"``. Preferred over ``--host`` when known: two profiles can
share one host, which makes ``databricks auth token --host`` fail
("Use --profile to specify which profile") → empty token → 401.
``--profile`` is always unambiguous.
:returns: Shell command that prints a bearer token.
"""
# --profile is unambiguous; --host fails when two profiles share a host.
selector = (
f"--profile {json.dumps(profile)}" if profile else f"--host {json.dumps(host.rstrip('/'))}"
)
# `--force-refresh` proactively refreshes a still-valid cached token
# (guards against a mid-session 401 on long gateway connections) but
# only exists in Databricks CLI >= v0.296.0. Probe `--help` and pass it
# only when supported: older CLIs reject the unknown flag → empty token
# → silent 401. Plain `auth token` still auto-refreshes expired tokens.
return (
'if [ -n "${DATABRICKS_BEARER:-}" ]; then '
'printf "%s\\n" "$DATABRICKS_BEARER"; '
"else force=''; "
"if databricks auth token --help 2>&1 | grep -q force-refresh; "
"then force=--force-refresh; fi; "
"env -u DATABRICKS_CONFIG_PROFILE "
f"databricks auth token {selector} "
"$force --output json | jq -r '.access_token'; fi"
)
def _databricks_codex_config_overrides(
*,
model: str,
base_url: str,
auth_command: str,
auth_refresh_interval_ms: int | None = None,
) -> list[str]:
"""Return TOML-fragment overrides for the Codex per-conversation config.
:param model: Model id to pin, e.g. ``"databricks-gpt-5-5"``.
:param base_url: Provider base URL from ucode state or the legacy profile
path, e.g. ``"https://example.databricks.com/ai-gateway/codex/v1"``.
:param auth_command: Shell command from ucode state or the legacy profile
path that prints
a bearer token, e.g. ``"databricks auth token --host ..."``.
:param auth_refresh_interval_ms: Refresh cadence in milliseconds,
e.g. ``900000``.
:returns: Codex TOML-fragment override strings.
"""
provider_name = "omnigent_databricks"
auth_command_json = json.dumps(auth_command)
return [
f"model={json.dumps(model)}",
f'model_provider="{provider_name}"',
(
"model_providers.omnigent_databricks="
'{name="Omnigent Databricks",'
f"base_url={json.dumps(base_url)},"
'auth={command="sh",'
f'args=["-c",{auth_command_json}],'
"timeout_ms=5000,"
f"refresh_interval_ms={auth_refresh_interval_ms or _GATEWAY_AUTH_REFRESH_MS}"
"},"
'wire_api="responses"}'
),
]
def _provider_codex_config_overrides(
*,
model: str | None,
base_url: str,
auth_command: str,
wire_api: str,
) -> list[str]:
"""Return Codex config overrides routing through a generic provider.
The OSS counterpart to :func:`_databricks_codex_config_overrides`: it
points Codex at a ``configure harnesses`` provider (key / gateway / local
serving the ``openai`` surface) by registering an ``omnigent_provider``
``model_provider`` with the provider's base URL, a bearer-token auth
command (``printf`` for a static key, or the provider's ``auth_command``),
and the provider's wire protocol — so a native Codex terminal routes
exactly like the in-process codex harness.
:param model: Model id to pin, e.g. ``"qwen/qwen3.7-plus"``. ``None``
omits the ``model`` override (Codex keeps its own default model name
while still routing through the provider).
:param base_url: The provider's openai-family base URL, e.g.
``"https://openrouter.ai/api/v1"``.
:param wire_api: The provider's configured wire protocol —
``"responses"`` (OpenAI / LiteLLM) or ``"chat"`` (OpenRouter and
most OSS-model gateways). codex >= 0.137 no longer accepts ``"chat"``
in its config (it hard-fails config load with ``wire_api = "chat" is
no longer supported``), so a ``"chat"`` value is coerced to
``"responses"`` — the only wire codex still speaks — before being
emitted. See the inline note for the OpenRouter caveat.
:returns: Codex TOML-fragment override strings.
"""
provider_name = "omnigent_provider"
auth_command_json = json.dumps(auth_command)
# codex >= 0.137 removed the chat/completions wire from its config schema:
# any provider block carrying wire_api="chat" makes codex hard-fail config
# load ("wire_api = \"chat\" is no longer supported"), which broke OSS /
# OpenRouter provider routing outright. "responses" is the only value codex
# still accepts (matching _databricks_codex_config_overrides above), so
# coerce here rather than emit a value codex rejects. Providers that genuinely
# only serve chat/completions (e.g. OpenRouter) will surface that mismatch at
# request time against a config codex can at least load, instead of failing
# to start at all.
effective_wire_api = "responses" if wire_api == "chat" else wire_api
overrides: list[str] = []
if model:
overrides.append(f"model={json.dumps(model)}")
overrides.append(f'model_provider="{provider_name}"')
overrides.append(
f"model_providers.{provider_name}="
'{name="Omnigent Provider",'
f"base_url={json.dumps(base_url)},"
'auth={command="sh",'
f'args=["-c",{auth_command_json}],'
"timeout_ms=5000,"
f"refresh_interval_ms={_GATEWAY_AUTH_REFRESH_MS}"
"},"
f'wire_api="{effective_wire_api}"}}'
)
return overrides
def _parse_optional_int(value: str | None) -> int | None:
"""Parse an optional integer env-var value.
:param value: Raw env-var value, e.g. ``"900000"``.
:returns: Parsed integer, or ``None`` when unset or invalid.
"""
if value is None:
return None
try:
return int(value)
except ValueError:
logger.warning("Ignoring invalid integer value %r", value)
return None
def _tool_signature(tools: list[ToolSpec]) -> str:
normalized = [
{
# Signature is used only as a cache key; missing fields
# are normalised to ``None`` so tool specs that differ
# only by absent keys still produce distinct signatures
# from ones that explicitly set empty strings.
"name": tool.get("name"),
"description": tool.get("description"),
"parameters": tool.get("parameters", {"type": "object", "properties": {}}),
}
for tool in tools
]
return json.dumps(normalized, sort_keys=True, separators=(",", ":"))
def _session_key(messages: list[Message]) -> str:
if messages:
last = messages[-1]
if last.get("session_id"):
return str(last["session_id"])
metadata = last.get("metadata", {})
if isinstance(metadata, dict) and metadata.get("session_id"):
return str(metadata["session_id"])
return "default"
def _extract_latest_user_content(
messages: list[Message],
) -> str | list[dict[str, Any]]:
"""
Extract the latest user message content.
Returns a plain string for text-only messages. When the
message carries multimodal content blocks, returns the
block list so the caller can pass structured input to the
Codex app-server.
:param messages: Conversation history.
:returns: A string prompt or a list of content block dicts.
"""
for msg in reversed(messages):
if msg.get("role") == "user":
content = msg.get("content")
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
return content
return json.dumps(content)
return ""
def _build_initial_prompt(
messages: list[Message],
) -> str | list[dict[str, Any]]:
"""
Build the initial prompt for a fresh Codex thread.
For single-message or single-user-message inputs, returns
the latest user content directly (may be multimodal). For
multi-turn history, serializes prior turns as text and
returns a plain string.
:param messages: Conversation history.
:returns: A string prompt or a list of content block dicts.
"""
user_messages = [msg for msg in messages if msg.get("role") == "user"]
if len(messages) <= 1 or len(user_messages) <= 1:
return _extract_latest_user_content(messages)
lines = ["Conversation so far:"]
for msg in messages:
role = str(msg.get("role", "user")).replace("_", " ")
raw_content = msg.get("content")
if raw_content is None:
content = ""
elif isinstance(raw_content, str):
content = raw_content
else:
content = json.dumps(raw_content, ensure_ascii=True)
lines.append(f"{role}: {content}")
lines.append("")
lines.append("Respond to the latest user message, using the conversation above as context.")
return "\n".join(lines)
def _prompt_for_turn(
messages: list[Message], *, is_new_thread: bool
) -> str | list[dict[str, Any]]:
"""
Choose the prompt payload for a Codex turn.
A fresh Codex thread must receive the full replayable
transcript so the harness can preserve parent history on
sub-agent calls. Resumed threads should only receive the
latest user message.
:param messages: Conversation history.
:param is_new_thread: ``True`` when starting a fresh Codex
thread.
:returns: A string prompt or a list of content block dicts.
"""
if is_new_thread:
return _build_initial_prompt(messages)
return _extract_latest_user_content(messages)
def _to_codex_input_items(
blocks: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""
Convert Responses API content blocks to Codex app-server
``turn/start`` input items.
The Codex app-server accepts ``{"type": "text", ...}`` for
text and ``{"type": "image", "image_url": "..."}`` for
images. This maps the Responses API ``input_image`` /
``input_text`` block types to the Codex wire format.
:param blocks: Responses API content block dicts (may include
``input_text``, ``input_image``, ``input_file``).
:returns: Codex input item dicts.
"""
items: list[dict[str, Any]] = []
for block in blocks:
block_type = block.get("type")
if block_type in ("input_text", "output_text", "text"):
text = block.get("text")
if isinstance(text, str) and text:
items.append({"type": "text", "text": text})
elif block_type == "input_image":
url = block.get("image_url")
if isinstance(url, str) and url:
items.append({"type": "image", "url": url})
elif block_type == "input_file":
# The Codex CLI does not support ``input_file`` blocks; it only
# accepts ``text``, ``image``, ``localImage``, ``skill``, and
# ``mention``. Fall back to inline text: decode the base64
# payload from the ``file_data`` data URI so the model can still
# reason about the file content.
file_data: str = block.get("file_data", "")
if file_data.startswith("data:"):
try:
meta, b64 = file_data.split(",", 1)
mime = meta.split(";")[0].replace("data:", "")
if not mime.startswith("text/"):
continue # binary files (PDF, etc.) can't be inlined as text
text = base64.b64decode(b64).decode("utf-8", errors="replace")
except Exception: # noqa: BLE001
text = ""
else:
text = file_data
if text:
items.append({"type": "text", "text": text})
return items
def _completed_agent_message_text(
item: CodexParams,
message_buffers: dict[str, str],
) -> tuple[str | None, str]:
"""Return the phase + final text for a completed Codex agent message.
:param item: The ``params["item"]`` dict from ``item/completed``.
:param message_buffers: Per-item accumulated delta text keyed by item id.
:returns: ``(phase, text)`` where ``phase`` may be ``None``.
"""
raw_completed_id = item.get("id")
if not isinstance(raw_completed_id, str):
return None, ""
completed_item_id = raw_completed_id
raw_completed_text = item.get("text")
completed_text: str = raw_completed_text if isinstance(raw_completed_text, str) else ""
phase = item.get("phase") if isinstance(item.get("phase"), str) else None
return phase, message_buffers.get(completed_item_id, completed_text)
def _latest_buffered_agent_message(message_buffers: dict[str, str]) -> str:
"""Return the most recent buffered assistant text from Codex deltas.
``turn/completed`` can arrive without a terminal ``item/completed``. In
that case the best available response is the last accumulated delta text.
:param message_buffers: Per-item accumulated delta text keyed by item id.
:returns: The most recent buffered assistant text, or ``""`` when absent.
"""
if not message_buffers:
return ""
return next(reversed(message_buffers.values()))
def _sandbox_mode(spec: OSEnvSpec | None) -> str:
if spec is None:
return "read-only"
sandbox = spec.sandbox or OSEnvSandboxSpec()
if sandbox.type == "none":
return "danger-full-access"
if sandbox.write_paths:
return "workspace-write"
return "read-only"
def _dynamic_tool_specs(tools: list[ToolSpec]) -> list[CodexParams]:
specs: list[CodexParams] = []
for tool in tools:
raw_name = tool.get("name")
# Codex App Server's ``dynamicTools`` protocol requires a
# non-empty ``name``; skip tool specs missing one rather
# than registering a tool the server will reject.
if not isinstance(raw_name, str) or not raw_name:
continue
raw_desc = tool.get("description")
# Description is optional to the server; pass ``""`` only at
# this JSON-RPC boundary where the wire format expects ``str``.
desc: str = raw_desc if isinstance(raw_desc, str) else ""
specs.append(
{
"name": raw_name,
"description": desc,
"inputSchema": tool.get("parameters", {"type": "object", "properties": {}}),
}
)
return specs
def _result_text(result: CodexToolResult) -> str:
if isinstance(result, str):
return result
try:
return json.dumps(result, ensure_ascii=False)
except TypeError:
return str(result)
def _dynamic_tool_result_payload(result: CodexToolResult) -> CodexParams:
classification = classify_tool_result(result)
return {
"success": classification.status == ToolCallStatus.SUCCESS,
"contentItems": [
{
"type": "inputText",
"text": _result_text(result),
}
],
}
@dataclass
class _PendingToolResult:
"""Tracks a dynamic tool invocation pending a Codex result event.
:param name: The tool name Codex asked Omnigent to run.
:param result: The raw tool result payload, or ``None`` if the tool
hasn't completed yet.
:param status: Classification of ``result`` (success / error / blocked).
:param error: Human-readable error message from the tool, or ``None``
when the call succeeded.
:param duration_ms: Wall-clock time spent running the tool, in ms.
"""
name: str
result: CodexToolResult | None = None
status: ToolCallStatus = ToolCallStatus.SUCCESS
error: str | None = None
duration_ms: float = 0.0
class _CodexAppServerSession:
def __init__(
self,
*,
codex_path: str,
cwd: str | None,
env: dict[str, str],
tool_executor: CodexToolExecutor | None,
codex_config_overrides: list[str] | None = None,
disable_native_tools: bool = False,
bundle_dir: Path | None = None,
skills_filter: str | list[str] = "all",
) -> None:
self._codex_path = codex_path
self._cwd = cwd
self._env = env
self._tool_executor = tool_executor
self._codex_config_overrides = list(codex_config_overrides or [])
self._disable_native_tools = disable_native_tools
self._bundle_dir = bundle_dir
self._skills_filter = skills_filter
self._proc: asyncio.subprocess.Process | None = None
self._reader_task: asyncio.Task[None] | None = None
self._stderr_task: asyncio.Task[None] | None = None
self._pending_requests: dict[int, asyncio.Future[CodexMessage]] = {}
self._events: asyncio.Queue[CodexMessage] = asyncio.Queue()
self._next_id = 1
self._started = False
self._loop: asyncio.AbstractEventLoop | None = None
self.thread_id: str | None = None
self.active_turn_id: str | None = None
# Last reasoning effort applied via ``thread/settings/update`` on the
# current thread. Effort is not part of the executor's session
# signature, so a change must be re-applied per turn; this is reset on
# a fresh thread so it is re-sent. ``turn/start`` carries no ``effort``
# field (it is silently dropped), hence the separate settings update.
self._applied_effort: str | None = None
self._recent_stderr: list[str] = []
self._recent_events: list[CodexMessage] = []
self._process_cwd: Path | None = None
# Private CODEX_HOME so the subprocess never writes to the user's ~/.codex/.
self._codex_home_dir: Path | None = None
# Most recent ``thread/tokenUsage/updated`` payload's ``last``
# turn breakdown, mapped to the wire shape. Consumed (and cleared)
# on the next ``turn/completed`` so each TurnComplete carries the
# usage for the turn that just finished.
self._last_turn_usage: dict[str, int] | None = None
async def start(self) -> None:
if self._started:
return
self._loop = asyncio.get_running_loop()
codex_home_root = Path(tempfile.gettempdir())
if self._cwd:
codex_home_root = Path(self._cwd) / ".codex-tmp"
codex_home_root.mkdir(parents=True, exist_ok=True)
self._codex_home_dir = Path(
tempfile.mkdtemp(prefix="omnigent-codex-home-", dir=str(codex_home_root))
)
# Populate the per-conversation CODEX_HOME's ``skills/`` subdir
# based on the spec's ``skills:`` field. Codex auto-discovers
# skills under ``$CODEX_HOME/skills/<name>/SKILL.md``; without
# this step the temp CODEX_HOME has no skills directory at all,
# so even ``skills: all`` would expose nothing. The shared helper
# is the same one the codex-native launch path uses, so both
# expose an identical skill surface.
populate_codex_skills_from_bundle(
self._codex_home_dir,
self._bundle_dir,
self._skills_filter,
)
# Bridge the user's authentication and provider config into the
# temp CODEX_HOME. The codex CLI reads ``auth.json`` (OAuth tokens
# for subscription auth) and ``config.toml`` (model provider
# definitions) from ``$CODEX_HOME``; without this step a freshly-
# created temp dir has neither, causing 401 Unauthorized errors
# for subscription-authenticated users.
_populate_codex_home_config(
self._codex_home_dir,
_codex_home_config_source_from_env(),
)
# Override CODEX_HOME so Codex stores its data (including conversation
# history) in a private temp directory rather than the user's ~/.codex/.
# This prevents subagent sessions from polluting the user's Codex history.
proc_env = {**self._env, "CODEX_HOME": str(self._codex_home_dir)}
try:
argv = [self._codex_path, "app-server"]
for override in self._codex_config_overrides:
argv.extend(["-c", override])
self._proc = await _create_subprocess_exec(
*argv,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=proc_env,
**_proc.spawn_kwargs(),
cwd=self._cwd or os.getcwd(),
)
self._reader_task = asyncio.create_task(self._reader_loop())
self._stderr_task = asyncio.create_task(self._stderr_loop())
await self._request(
"initialize",
{
"clientInfo": {
"name": "omnigent",
"version": "0.1",
},
"capabilities": {
"experimentalApi": True,
},
},
)
self._started = True
except Exception:
await self.close()
raise
async def close(self) -> None:
current_loop = asyncio.get_running_loop()
if self._loop is not None and self._loop is not current_loop:
if self._proc is not None and self._proc.returncode is None:
_terminate_process_tree(self._proc)
self._pending_requests.clear()
if self._proc is not None:
close_subprocess_transport(self._proc)
self._started = False
self._proc = None
self._reader_task = None
self._stderr_task = None
self._loop = None
self.thread_id = None
self.active_turn_id = None
self._cleanup_process_cwd()
return
if self._proc is not None and self._proc.returncode is None:
_terminate_process_tree(self._proc)
try:
await asyncio.wait_for(self._proc.wait(), timeout=5)
except asyncio.TimeoutError:
_kill_process_tree(self._proc)
await self._proc.wait()
stdin = self._proc.stdin if self._proc is not None else None
if stdin is not None:
with suppress(Exception):
stdin.close()
wait_closed = getattr(stdin, "wait_closed", None)
if callable(wait_closed):
with suppress(Exception):
await wait_closed()
for task in (self._reader_task, self._stderr_task):
if task is not None:
task.cancel()
with suppress(asyncio.CancelledError):
await task
for future in self._pending_requests.values():
if not future.done():
future.cancel()
self._pending_requests.clear()
if self._proc is not None:
close_subprocess_transport(self._proc)
self._started = False
self._proc = None
self._reader_task = None
self._stderr_task = None
self._loop = None
self.thread_id = None
self.active_turn_id = None
self._recent_events.clear()
self._cleanup_process_cwd()
def _cleanup_process_cwd(self) -> None:
if self._codex_home_dir is not None:
shutil.rmtree(self._codex_home_dir, ignore_errors=True)
self._codex_home_dir = None
def _record_event(self, message: CodexMessage) -> None:
self._recent_events.append(message)
if len(self._recent_events) > 20:
self._recent_events.pop(0)
def _format_recent_events(self) -> list[CodexParams]:
formatted: list[CodexParams] = []
for message in self._recent_events[-8:]:
params = message.get("params")
item = params.get("item") if isinstance(params, dict) else None
item_payload: CodexParams = item if isinstance(item, dict) else {}
formatted.append(
{
"method": message.get("method"),
"turnId": params.get("turnId") if isinstance(params, dict) else None,
"itemType": item_payload.get("type"),
"itemPhase": item_payload.get("phase"),
"itemId": item_payload.get("id"),
"callId": params.get("callId") if isinstance(params, dict) else None,
"tool": params.get("tool") if isinstance(params, dict) else None,
}
)
return formatted
async def _drain_turn_completed_tail(
self,
*,
active_turn_id: str,
message_buffers: dict[str, str],
final_response: str,
) -> str:
"""Collect a trailing final-answer item that arrives after ``turn/completed``.
Some Codex app-server turns emit ``turn/completed`` slightly before the
terminal ``item/completed`` with ``phase="final_answer"``. Drain a
short tail window so we don't lock in ``response=""`` when the final
assistant item is still in flight.
:param active_turn_id: The current turn id.
:param message_buffers: Accumulated delta text keyed by item id.
:param final_response: The response text accumulated so far.
:returns: The best final response observed during the drain window.
"""
deadline = time.monotonic() + _TURN_COMPLETED_DRAIN_SECONDS
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
return final_response
try:
message = await asyncio.wait_for(self._events.get(), timeout=remaining)
except asyncio.TimeoutError:
return final_response
self._record_event(message)
params = message.get("params", {})
if not isinstance(params, dict):
continue
event_turn_id = params.get("turnId")
if (
isinstance(event_turn_id, str)
and event_turn_id
and event_turn_id != active_turn_id
):
continue
if message.get("method") == "item/agentMessage/delta":
item_id = params.get("itemId")
delta = params.get("delta")
if isinstance(item_id, str) and isinstance(delta, str) and delta:
prior = message_buffers.get(item_id, "")
message_buffers[item_id] = f"{prior}{delta}"
if not final_response:
final_response = message_buffers[item_id]
continue
if message.get("method") != "item/completed":
continue
item = params.get("item", {})
if not isinstance(item, dict) or item.get("type") != "agentMessage":
continue
phase, completed_text = _completed_agent_message_text(item, message_buffers)
if phase == "commentary":
item_id = item.get("id")
if isinstance(item_id, str):
message_buffers.pop(item_id, None)
continue
if phase == "final_answer" or phase is None:
final_response = completed_text
if phase == "final_answer":
return final_response
async def run_turn(
self,
*,
messages: list[Message],
tools: list[ToolSpec],
system_prompt: str,
model: str,
cwd: str,
sandbox: str,
reasoning_effort: str | None = None,
) -> AsyncIterator[ExecutorEvent]:
await self.start()
assert self._proc is not None
is_new_thread = self.thread_id is None
if is_new_thread:
params: CodexParams = {
"approvalPolicy": "never",
"cwd": cwd,
"model": model,
"sandbox": sandbox,
}
if system_prompt:
params["developerInstructions"] = system_prompt
if tools:
params["dynamicTools"] = _dynamic_tool_specs(tools)
features: CodexParams = {"unified_exec": False}
if self._disable_native_tools:
features["shell_tool"] = False
params["config"] = {"features": features}
response = await self._request("thread/start", params)
thread = response.get("result", {}).get("thread", {})
raw_thread_id = thread.get("id")
# The Codex App Server is required to return a thread id
# on ``thread/start``. Propagate ``None`` so the assert
# below fails loud for a protocol violation instead of
# silently carrying an empty-string thread id.
self.thread_id = raw_thread_id if isinstance(raw_thread_id, str) else None
# Fresh thread: forget the prior thread's applied effort so the
# settings update below re-sends it for this thread.
self._applied_effort = None
assert self.thread_id is not None
prompt = _prompt_for_turn(messages, is_new_thread=is_new_thread)
if isinstance(prompt, list):
turn_input = _to_codex_input_items(prompt)
else:
turn_input = [{"type": "text", "text": prompt}]
# Apply reasoning effort via ``thread/settings/update``: Codex's
# ``TurnStartParams`` has no ``effort`` field, so an ``effort`` set on
# ``turn/start`` is silently dropped by serde and never takes effect.
# ``ThreadSettingsUpdateParams`` is where ``model``/``effort`` live —
# the same path the TUI ``/model`` picker uses. Deduped against the
# last value applied on this thread to avoid a redundant per-turn RPC.
if reasoning_effort and reasoning_effort != self._applied_effort:
await self._request(
"thread/settings/update",
{"threadId": self.thread_id, "effort": reasoning_effort},
)
self._applied_effort = reasoning_effort
turn_params: CodexParams = {
"threadId": self.thread_id,
"input": turn_input,
}
start_response = await self._request(
"turn/start",
turn_params,
)
raw_active_turn_id = start_response.get("result", {}).get("turn", {}).get("id")
if not isinstance(raw_active_turn_id, str) or not raw_active_turn_id:
yield ExecutorError(message="Codex App Server did not return a turn id")
return
active_turn_id: str = raw_active_turn_id
self.active_turn_id = active_turn_id
while not self._events.empty():
queued_message = self._events.get_nowait()
queued_turn_id: str | None = None
queued_params = queued_message.get("params")
if isinstance(queued_params, dict):
raw_queued_turn_id = queued_params.get("turnId")
if isinstance(raw_queued_turn_id, str):
queued_turn_id = raw_queued_turn_id
if queued_turn_id is not None and queued_turn_id == active_turn_id:
self._events.put_nowait(queued_message)
break
message_buffers: dict[str, str] = {}
pending_tool_results: dict[str, _PendingToolResult] = {}
final_response = ""
observed_turn_id: str | None = None
def _event_turn_matches(params: CodexParams) -> bool:
nonlocal active_turn_id, observed_turn_id
raw_event_turn_id = params.get("turnId")
# Missing turnId means the event is not scoped to a turn
# (e.g. global notifications); treat it as matching the
# current turn.
if not isinstance(raw_event_turn_id, str) or not raw_event_turn_id:
return True
event_turn_id: str = raw_event_turn_id
if event_turn_id == active_turn_id:
observed_turn_id = event_turn_id
return True
if observed_turn_id is None:
# A first event that *terminates* the turn (final-answer
# item) is almost certainly a stale tail from the prior
# turn, not a Codex-quirk rewrite. Drop it instead of
# adopting it as the new turn's response.
item = params.get("item")
if isinstance(item, dict) and item.get("phase") == "final_answer":
logger.info(
"Codex dropping stale final-answer event (turn_id=%s, active_turn_id=%s).",
event_turn_id,
active_turn_id,
)
return False
# Diagnostic: dump recent events alongside adopt so
# CI logs surface the full event sequence.
logger.info(
"Codex turn/start returned turn_id=%s but first event used "
"turn_id=%s; adopting observed id. recent_events=%s",
active_turn_id,
event_turn_id,
self._format_recent_events(),
)
active_turn_id = event_turn_id
self.active_turn_id = event_turn_id
observed_turn_id = event_turn_id
return True
return False
try:
while True:
event_task = asyncio.ensure_future(self._events.get())
idle_seconds = 0.0
try:
while True:
done, _ = await asyncio.wait(
{event_task}, timeout=_TURN_EVENT_WARN_SECONDS
)
if event_task in done:
break
idle_seconds += _TURN_EVENT_WARN_SECONDS
pending_tool_summaries = [
{
"call_id": call_id,
"name": pending.name,
"status": pending.status.value,
"error": pending.error,
}
for call_id, pending in pending_tool_results.items()
]
logger.warning(
"Codex turn %s has been idle for %ds "
"(thread_id=%s recent_events=%s recent_stderr=%s "
"pending_tool_results=%s); still waiting.",
active_turn_id,
int(idle_seconds),
self.thread_id,
self._format_recent_events(),
self._recent_stderr[-8:],
pending_tool_summaries,
)
except BaseException:
event_task.cancel()
with suppress(BaseException):
await event_task
raise
message = event_task.result()
self._record_event(message)
raw_method = message.get("method")
method: str | None = raw_method if isinstance(raw_method, str) else None
params = message.get("params", {})
if method == "item/tool/call":
if not _event_turn_matches(params):
continue
raw_call_id = params.get("callId")
raw_tool_name = params.get("tool")
# ``item/tool/call`` without a callId/tool is a
# protocol violation from the Codex App Server;
# skip rather than registering a pending result
# under an empty key.
if not isinstance(raw_call_id, str) or not isinstance(raw_tool_name, str):
continue
call_id: str = raw_call_id
tool_name: str = raw_tool_name
tool_args = params.get("arguments", {})
if not isinstance(tool_args, dict):
tool_args = {}
started = time.monotonic()
yield ToolCallRequest(
name=tool_name,
args=tool_args,
metadata={"call_id": call_id},
)
result = await self._execute_dynamic_tool(tool_name, tool_args)
classification = classify_tool_result(result)
duration_ms = (time.monotonic() - started) * 1000
pending_tool_results[call_id] = _PendingToolResult(
name=tool_name,
result=result,
status=classification.status,
error=classification.error,
duration_ms=duration_ms,
)
logger.info(
"Codex dynamic tool result: turn_id=%s call_id=%s "
"tool=%s status=%s result=%s",
active_turn_id,
call_id,
tool_name,
classification.status.value,
_result_text(result),
)
await self._send_response(
int(message["id"]),
_dynamic_tool_result_payload(result),
)
yield ToolCallComplete(
name=tool_name,
status=classification.status,
result=result,
error=classification.error,
duration_ms=duration_ms,
metadata={"call_id": call_id},
)
continue
if method == "item/agentMessage/delta":
if not _event_turn_matches(params):
continue
raw_item_id = params.get("itemId")
raw_delta = params.get("delta")
if not isinstance(raw_item_id, str) or not isinstance(raw_delta, str):
continue
if not raw_delta:
continue
item_id: str = raw_item_id
delta: str = raw_delta
prior = message_buffers.get(item_id)
message_buffers[item_id] = (prior if prior is not None else "") + delta
yield TextChunk(text=delta)
continue
if method in ("item/reasoning/textDelta", "item/reasoning/summaryTextDelta"):
if not _event_turn_matches(params):
continue
raw_reasoning_delta = params.get("delta")
if not isinstance(raw_reasoning_delta, str) or not raw_reasoning_delta:
continue
yield ReasoningChunk(delta=raw_reasoning_delta, event_type="reasoning_text")
continue
if method == "item/completed":
if not _event_turn_matches(params):
continue
item = params.get("item", {})
if not isinstance(item, dict):
continue
raw_item_type = item.get("type")
item_type: str | None = (
raw_item_type if isinstance(raw_item_type, str) else None
)
if item_type == "agentMessage":
raw_completed_id = item.get("id")
if not isinstance(raw_completed_id, str):
continue
completed_item_id: str = raw_completed_id
raw_completed_text = item.get("text")
completed_text: str = (
raw_completed_text if isinstance(raw_completed_text, str) else ""
)
if completed_item_id not in message_buffers and completed_text:
message_buffers[completed_item_id] = completed_text
yield TextChunk(text=completed_text)
phase, completed_text = _completed_agent_message_text(
item, message_buffers
)
if phase == "commentary":
message_buffers.pop(completed_item_id, None)
continue
if phase == "final_answer" or phase is None:
final_response = completed_text
if phase == "final_answer":
# Diagnostic: log response head + turn id so
# ghost-events surface in CI logs.
logger.info(
"Codex TurnComplete: turn_id=%s response_head=%r",
active_turn_id,
final_response[:120],
)
turn_usage = self._last_turn_usage
self._last_turn_usage = None
_notify_usage_from_dict(model=model, usage=turn_usage)
yield TurnComplete(response=final_response, usage=turn_usage)
return
continue
if item_type == "dynamicToolCall":
raw_dyn_call_id = item.get("id")
if not isinstance(raw_dyn_call_id, str):
continue
pending_tool_results.pop(raw_dyn_call_id, None)
continue
if method == "thread/tokenUsage/updated":
self._last_turn_usage = _extract_codex_last_turn_usage(params)
continue
if method == "turn/completed":
turn = params.get("turn", {})
raw_completed_turn_id = turn.get("id")
completed_turn_id: str | None = (
raw_completed_turn_id
if isinstance(raw_completed_turn_id, str) and raw_completed_turn_id
else None
)
if completed_turn_id is not None and completed_turn_id != active_turn_id:
logger.warning(
"Codex emitted turn/completed for unexpected "
"turn_id=%s while active_turn_id=%s",
completed_turn_id,
active_turn_id,
)
continue
if not final_response:
final_response = _latest_buffered_agent_message(message_buffers)
if not final_response:
final_response = await self._drain_turn_completed_tail(
active_turn_id=active_turn_id,
message_buffers=message_buffers,
final_response=final_response,
)
turn_usage = self._last_turn_usage
self._last_turn_usage = None
_notify_usage_from_dict(model=model, usage=turn_usage)
yield TurnComplete(response=final_response, usage=turn_usage)
return
if method == "turn/failed":
if isinstance(params, dict) and params.get("willRetry") is True:
continue
turn = params.get("turn", {}) if isinstance(params, dict) else {}
raw_failed_turn_id = turn.get("id")
failed_turn_id: str | None = (
raw_failed_turn_id
if isinstance(raw_failed_turn_id, str) and raw_failed_turn_id
else None
)
if failed_turn_id is not None and failed_turn_id != active_turn_id:
continue
error_text = str(
params.get("message")
or turn.get("error")
or "Codex App Server turn failed"
)
# turn/failed is a provider/runtime-level turn error
# (e.g. tool exit code, transient provider issue) —
# mark retryable so the workflow's retry policy
# reissues instead of surfacing as permanent.
yield ExecutorError(message=error_text, retryable=True)
return
if method == "error":
if isinstance(params, dict) and params.get("willRetry") is True:
continue
# JSON-RPC-shaped error frames from the app server
# carry ``code`` / ``message`` / ``data``. Some error
# paths populate only ``code``+``data`` and leave
# ``message`` empty, which used to surface as the
# bare fallback ``"Codex App Server error"`` —
# giving the user no indication of WHY codex failed.
# Always emit every populated field so a config
# mismatch (e.g. a Claude model name passed to a
# codex harness, an invalid profile) gets a full
# diagnostic trail.
detail = _format_codex_error_params(params)
if self._recent_stderr:
detail = f"{detail}; stderr: {' | '.join(self._recent_stderr[-5:])}"
# method==error from the app server is a runtime
# failure (often a tool-execution or provider issue,
# e.g. a bash printf failure); treat as retryable.
yield ExecutorError(message=detail, retryable=True)
return
finally:
if self.active_turn_id == active_turn_id:
self.active_turn_id = None
async def enqueue_message(self, content: CodexEnqueuedContent) -> bool:
if self.thread_id is None or self.active_turn_id is None:
return False
if isinstance(content, str):
text = content
else:
text = json.dumps(content, ensure_ascii=True)
try:
response = await self._request(
"turn/steer",
{
"threadId": self.thread_id,
"expectedTurnId": self.active_turn_id,
"input": [
{
"type": "text",
"text": text,
}
],
},
)
except Exception as exc: # noqa: BLE001 — steer is best-effort; any failure surfaces as False
logger.debug("Codex turn/steer failed: %s", exc)
return False
turn_id = str(response.get("result", {}).get("turnId") or self.active_turn_id)
if turn_id:
self.active_turn_id = turn_id
return True
async def interrupt_turn(self) -> bool:
if self.thread_id is None or self.active_turn_id is None:
return False
await self._request(
"turn/interrupt",
{
"threadId": self.thread_id,
"turnId": self.active_turn_id,
},
)
return True
async def _execute_dynamic_tool(
self,
tool_name: str,
tool_args: ToolArgs,
) -> CodexToolResult:
if self._tool_executor is None:
return {"error": f"No tool executor available for '{tool_name}'"}
try:
raw = self._tool_executor(tool_name, tool_args)
resolved: CodexToolResult = await raw if isinstance(raw, Awaitable) else raw
if isinstance(resolved, dict):
return resolved
return {"result": resolved}
except Exception as exc: # noqa: BLE001 — tool errors are surfaced to Codex via the JSON response envelope
return {"error": str(exc)}
async def _request(self, method: str, params: CodexParams) -> CodexMessage:
request_id = self._next_id
self._next_id += 1
loop = asyncio.get_running_loop()
future: asyncio.Future[CodexMessage] = loop.create_future()
self._pending_requests[request_id] = future
await self._send_message(
{
"id": request_id,
"method": method,
"params": params,
}
)
response = await future
error = response.get("error")
if error:
raise RuntimeError(str(error))
return response
async def _send_response(self, request_id: int, result: CodexParams) -> None:
await self._send_message(
{
"id": request_id,
"result": result,
}
)
async def _send_message(self, payload: CodexMessage) -> None:
assert self._proc is not None and self._proc.stdin is not None
self._proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
await self._proc.stdin.drain()
@staticmethod
async def _iter_stream_chunks(stream: asyncio.StreamReader) -> AsyncIterator[bytes]:
while True:
chunk = await stream.read(_STREAM_READ_CHUNK_SIZE)
if not chunk:
break
yield chunk
@staticmethod
async def _iter_stream_lines(stream: asyncio.StreamReader) -> AsyncIterator[bytes]:
buffer = bytearray()
async for chunk in _CodexAppServerSession._iter_stream_chunks(stream):
buffer.extend(chunk)
while True:
newline_index = buffer.find(b"\n")
if newline_index < 0:
break
line = bytes(buffer[: newline_index + 1])
del buffer[: newline_index + 1]
yield line
if buffer:
yield bytes(buffer)
async def _reader_loop(self) -> None:
assert self._proc is not None and self._proc.stdout is not None
try:
async for line in self._iter_stream_lines(self._proc.stdout):
raw = line.decode("utf-8", errors="replace").strip()
if not raw:
continue
message = json.loads(raw)
if (
"id" in message
and "method" not in message
and ("result" in message or "error" in message)
):
future = self._pending_requests.pop(int(message["id"]), None)
if future is not None and not future.done():
future.set_result(message)
continue
await self._events.put(message)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 — reader loop logs and exits on any unexpected error # pragma: no cover - defensive
logger.debug("Codex App Server reader loop ended: %s", exc)
async def _stderr_loop(self) -> None:
assert self._proc is not None and self._proc.stderr is not None
try:
async for line in self._iter_stream_lines(self._proc.stderr):
text = line.decode("utf-8", errors="replace").rstrip()
if not text:
continue
if len(text) >= _STDERR_CHUNK_LIMIT:
text = text[:_STDERR_CHUNK_LIMIT] + "...[truncated]"
self._recent_stderr.append(text)
if len(self._recent_stderr) > 20:
self._recent_stderr.pop(0)
logger.debug("codex app-server stderr: %s", text)
except asyncio.CancelledError:
raise
@dataclass
class _CodexSessionState:
app_session: _CodexAppServerSession | None = None
signature: tuple[str, str, str, str] | None = None
class _AppSessionFactory(Protocol):
"""Constructor signature shared by ``_CodexAppServerSession`` and test fakes.
Tests inject this via ``CodexExecutor(app_session_factory=...)`` to
substitute a stub session (see ``tests/test_codex_executor.py``). The
Protocol spells out the exact kwargs the factory receives.
"""
def __call__(
self,
*,
codex_path: str,
cwd: str | None,
env: dict[str, str],
tool_executor: CodexToolExecutor | None,
codex_config_overrides: list[str] | None,
disable_native_tools: bool,
bundle_dir: Path | None,
skills_filter: str | list[str],
) -> _CodexAppServerSession: ...
def _default_app_session_factory(
*,
codex_path: str,
cwd: str | None,
env: dict[str, str],
tool_executor: CodexToolExecutor | None,
codex_config_overrides: list[str] | None,
disable_native_tools: bool,
bundle_dir: Path | None,
skills_filter: str | list[str],
) -> _CodexAppServerSession:
return _CodexAppServerSession(
codex_path=codex_path,
cwd=cwd,
env=env,
tool_executor=tool_executor,
codex_config_overrides=codex_config_overrides,
disable_native_tools=disable_native_tools,
bundle_dir=bundle_dir,
skills_filter=skills_filter,
)
class CodexExecutor(Executor):
def __init__(
self,
*,
cwd: str | None = None,
os_env: OSEnvSpec | None = None,
model: str | None = None,
codex_path: str | None = None,
app_session_factory: _AppSessionFactory | None = None,
gateway: bool = False,
databricks_profile: str | None = None,
model_provider_override: str | None = None,
gateway_host: str | None = None,
base_url_override: str | None = None,
gateway_auth_command: str | None = None,
gateway_auth_refresh_interval_ms: str | None = None,
enable_web_search: bool = True,
disable_native_tools: bool = False,
retry_policy: RetryPolicy | None = None,
bundle_dir: Path | None = None,
agent_name: str | None = None,
skills_filter: str | list[str] = "all",
) -> None:
"""Create a CodexExecutor.
:param cwd: Working directory for the Codex subprocess.
:param os_env: Optional OS environment / sandbox spec.
:param model: Override the model name, e.g. ``"databricks-gpt-5-4-mini"``.
:param codex_path: Absolute path to a ``codex`` CLI binary. When
``None`` the executor searches ``PATH``.
:param app_session_factory: Test hook for injecting a stub session.
:param gateway: When ``True``, route through a vendor-neutral gateway
(base URL + bearer-token command + model). The Databricks AI
gateway (Codex Responses API at ``/ai-gateway/codex/v1``) is one
producer of this transport; generic providers are another.
:param databricks_profile: Databricks-specific config profile from
``~/.databrickscfg``, e.g. ``"<your-profile>"``. Only used on the
Databricks producer path (deriving base URL / auth command from
the profile when not supplied directly, and for token refresh).
``None`` falls back to ``DATABRICKS_CONFIG_PROFILE`` then the
first valid profile.
:param model_provider_override: A codex ``model_provider`` id to pin
via a ``-c`` override, e.g. ``"openai"`` (force the built-in
provider so a custom default in the user's ``~/.codex/config.toml``
cannot shadow a subscription) or ``"Databricks"`` (a custom
``[model_providers.X]`` table from that same file, which this
executor bridges into the per-session ``CODEX_HOME``). Set from
``HARNESS_CODEX_MODEL_PROVIDER``. Mutually exclusive with
*gateway* — the gateway path pins its own generated provider.
:param gateway_host: Gateway workspace host origin, e.g.
``"https://example.databricks.com"``. Set from
``HARNESS_CODEX_GATEWAY_HOST`` (written by the Omnigent workflow
layer). When set, skips profile host lookup and requires the
gateway base URL and auth command values.
:param base_url_override: Override the Codex gateway base URL instead
of deriving it from the profile host. Set from
``HARNESS_CODEX_GATEWAY_BASE_URL`` (written by the Omnigent workflow
layer). Required whenever ``gateway_host`` is set.
:param gateway_auth_command: Shell command that prints a bearer token,
e.g.
``"databricks auth token --host https://example.databricks.com ..."``
or ``"printf %s sk-or-..."``. Set from
``HARNESS_CODEX_GATEWAY_AUTH_COMMAND``.
:param gateway_auth_refresh_interval_ms: Refresh cadence as a string,
e.g. ``"900000"``. Set from
``HARNESS_CODEX_GATEWAY_AUTH_REFRESH_INTERVAL_MS``.
:param enable_web_search: Leave Codex's built-in ``web_search`` tool
enabled. Set ``False`` to force the model to use only
Omnigent-bridged tools.
:param disable_native_tools: When True, disable supported native
Codex tools for the turn.
:param retry_policy: The spec's ``llm.retry`` budget. Threads
``policy.codex_cli.env()`` (e.g. ``OPENAI_MAX_RETRIES``,
``OPENAI_TIMEOUT``) into the Codex CLI subprocess
environment so transient gateway failures are retried with
spec-controlled backoff. ``None`` resolves to
``RetryPolicy()`` defaults — see Phase 1f of
``designs/RETRY_ACROSS_HARNESSES.md``.
:param bundle_dir: The agent bundle's extracted on-disk path.
When set, ``<bundle_dir>/skills/<name>/SKILL.md`` files are
symlinked into the per-conversation ``$CODEX_HOME/skills/``
so Codex auto-discovers them. ``None`` skips bundle-skill
wiring (host-installed ``~/.codex/skills/`` only, subject to
``skills_filter``).
:param agent_name: Optional agent display name. Reserved for
future use (e.g. namespacing bundled skills with an
agent-specific prefix); currently unused by codex.
:param skills_filter: Host-skill filter (``"all"`` / ``"none"``
/ ``list[str]``). ``"all"`` (default) symlinks every skill
from sources (bundle skills + ``~/.codex/skills/``) into
``$CODEX_HOME/skills/``; ``"none"`` leaves the directory
empty so Codex sees no skills; a list exposes only the
named skills (looked up across all sources, bundle wins
on name conflict).
"""
self._cwd = cwd
self._os_env_spec = os_env
self._model_override = model
self._gateway = gateway
self._databricks_profile = databricks_profile
self._gateway_host = gateway_host.rstrip("/") if gateway_host else None
self._base_url_override = base_url_override
self._gateway_auth_command = gateway_auth_command
self._gateway_auth_refresh_interval_ms = _parse_optional_int(
gateway_auth_refresh_interval_ms
)
self._enable_web_search = enable_web_search
self._disable_native_tools = disable_native_tools
self._bundle_dir = bundle_dir
self._agent_name = agent_name
self._skills_filter = skills_filter
resolved_codex = codex_path or _find_codex_cli()
if not resolved_codex:
raise ImportError("CodexExecutor requires the 'codex' CLI on PATH.")
self._codex_path = resolved_codex
self._env = _clean_codex_env(_declared_passthrough(self._os_env_spec))
# Retry policy → OpenAI SDK env vars (Codex uses the OpenAI
# SDK internally). Speculative — empirical audit pending.
self._retry_policy = retry_policy if retry_policy is not None else RetryPolicy()
self._env.update(self._retry_policy.codex_cli.env())
self._codex_config_overrides: list[str] = []
if model_provider_override is not None and gateway:
# Both would fight over model_provider in the -c overrides; the
# AP producer must emit exactly one routing mechanism.
raise OSError(
"CodexExecutor received both gateway=True and "
"model_provider_override; these routing mechanisms are "
"mutually exclusive."
)
if model_provider_override is not None:
# Pin the provider by name. json.dumps yields a valid TOML basic
# string (proper quoting/escaping) for the -c override value.
self._codex_config_overrides.append(
f"model_provider={json.dumps(model_provider_override)}"
)
# True when the gateway transport was derived from a ~/.databrickscfg
# profile (no gateway base URL supplied directly). Gates the
# Databricks-specific default model in :meth:`run_turn`; the neutral
# generic-provider gateway path leaves this False so it never selects
# a ``databricks-*`` model.
self._gateway_uses_databricks_profile = False
if gateway:
host = self._gateway_host
# ``effective_model`` resolves to a concrete model for the codex
# config. On the Databricks-profile-derivation branch (no gateway
# host or base URL supplied directly) a ``databricks-*`` default is
# legitimate Databricks behavior; on the directly-supplied neutral
# gateway path the Omnigent producer must have resolved a model, and the
# path never falls back to a ``databricks-*`` model.
effective_model: str
if host is None:
# No gateway host supplied directly: derive the transport from
# a Databricks profile (the Databricks producer's fallback).
creds = _read_databrickscfg(databricks_profile)
host = (
creds.host
if creds is not None
else _read_databrickscfg_host(databricks_profile)
)
if not host:
raise OSError(
"CodexExecutor(gateway=True) requires gateway credentials via "
"the gateway base URL / auth command or a valid "
"~/.databrickscfg profile."
)
host = host.rstrip("/")
base_url = (
base_url_override
if base_url_override is not None
else _databricks_codex_base_url(host)
)
auth_command = (
gateway_auth_command
if gateway_auth_command is not None
else _databricks_codex_auth_command(host, databricks_profile)
)
# Databricks-profile path: a Databricks default is legitimate.
self._gateway_uses_databricks_profile = True
effective_model = model or _DATABRICKS_CODEX_DEFAULT_MODEL
else:
if base_url_override is None:
raise OSError(
"CodexExecutor(gateway=True) with a gateway workspace host "
"requires HARNESS_CODEX_GATEWAY_BASE_URL."
)
if gateway_auth_command is None:
raise OSError(
"CodexExecutor(gateway=True) with a gateway workspace host "
"requires HARNESS_CODEX_GATEWAY_AUTH_COMMAND."
)
base_url = base_url_override
auth_command = gateway_auth_command
if model is None:
# Directly-supplied neutral gateway: the Omnigent producer always
# resolves a concrete model (spec > provider default >
# catalog default) before spawning. Fail loud rather than
# silently selecting a ``databricks-*`` default.
raise OSError(
"CodexExecutor(gateway=True) with a gateway base URL requires a "
"model: the Omnigent producer must resolve one before spawning."
)
effective_model = model
# ``DATABRICKS_HOST`` is read by the Databricks ``databricks auth
# token`` fallback auth command; harmless for a generic gateway
# whose auth command is a static ``printf %s <key>``.
self._env["DATABRICKS_HOST"] = host
self._codex_config_overrides.extend(
_databricks_codex_config_overrides(
model=effective_model,
base_url=base_url,
auth_command=auth_command,
auth_refresh_interval_ms=self._gateway_auth_refresh_interval_ms,
)
)
if not enable_web_search:
# Disable Codex's built-in web_search tool so the model can only reach
# tools exposed by Omnigent as dynamicTools. The top-level web_search
# key accepts "live", "cached", or "disabled".
self._codex_config_overrides.append('web_search="disabled"')
self._tool_executor: CodexToolExecutor | None = None
self._session_states: dict[str, _CodexSessionState] = {}
self._app_session_factory: _AppSessionFactory = (
app_session_factory
if app_session_factory is not None
else _default_app_session_factory
)
def supports_streaming(self) -> bool:
return True
def supports_tool_calling(self) -> bool:
return True
def handles_tools_internally(self) -> bool:
return True
def supports_live_message_queue(self) -> bool:
return True
async def interrupt_session(self, session_key: str) -> bool:
state = self._session_states.get(session_key)
if state is None or state.app_session is None:
return False
# Best-effort interrupt to halt the in-flight turn; a failure just
# falls through to the close below.
try:
await asyncio.wait_for(
state.app_session.interrupt_turn(),
timeout=0.5,
)
except Exception as exc: # noqa: BLE001 — interrupt is best-effort
logger.warning(
"Codex turn interrupt failed for session %s: %s",
session_key,
exc,
)
# Always drop the session (resets thread_id) so the next turn starts a
# fresh thread and replays full history. A resumed thread sends only
# the latest user message, which would bypass the runner's
# "[System: interrupted]" marker and silently continue the abandoned
# request. See claude_sdk_executor.interrupt_session for the rationale.
try:
await self.close_session(session_key)
return True
except Exception as exc: # noqa: BLE001 — close failures surface via False return
logger.warning(
"Codex session close after interrupt failed for session %s: %s",
session_key,
exc,
)
return False
async def enqueue_session_message(
self, session_key: str, content: CodexEnqueuedContent
) -> bool:
state = self._session_states.get(session_key)
if state is None or state.app_session is None:
return False
return await state.app_session.enqueue_message(content)
async def close_session(self, session_key: str) -> None:
state = self._session_states.pop(session_key, None)
if state is not None and state.app_session is not None:
await state.app_session.close()
async def close(self) -> None:
keys = list(self._session_states.keys())
for key in keys:
await self.close_session(key)
async def _ensure_app_session(
self,
state: _CodexSessionState,
*,
signature: tuple[str, str, str, str],
effective_cwd: str,
) -> _CodexAppServerSession:
if state.signature == signature and state.app_session is not None:
return state.app_session
if state.app_session is not None:
await state.app_session.close()
app_session = self._app_session_factory(
codex_path=self._codex_path,
cwd=effective_cwd,
env=self._env,
tool_executor=self._tool_executor,
codex_config_overrides=self._codex_config_overrides,
disable_native_tools=self._disable_native_tools,
bundle_dir=self._bundle_dir,
skills_filter=self._skills_filter,
)
state.app_session = app_session
state.signature = signature
return app_session
async def run_turn(
self,
messages: list[Message],
tools: list[ToolSpec],
system_prompt: str,
config: ExecutorConfig | None = None,
) -> AsyncIterator[ExecutorEvent]:
cfg = config or ExecutorConfig()
session_key = _session_key(messages)
state = self._session_states.setdefault(session_key, _CodexSessionState())
# cfg.model (per-request /model override) wins over the spec
# default (HARNESS_CODEX_MODEL → self._model_override). The final
# fallback is the Databricks default only on the Databricks-profile
# gateway path; the neutral gateway path (and the built-in path) never
# select a ``databricks-*`` model.
model = (
cfg.model
or self._model_override
or (
_DATABRICKS_CODEX_DEFAULT_MODEL
if self._gateway_uses_databricks_profile
else _OPENAI_CODEX_DEFAULT_MODEL
)
)
effective_cwd = (
self._cwd or (self._os_env_spec.cwd if self._os_env_spec else None) or os.getcwd()
)
signature = (
model,
system_prompt,
effective_cwd,
_tool_signature(tools),
)
try:
reasoning_effort = validate_effort(
cfg.extra.get("reasoning_effort"), "codex", CODEX_EFFORTS
)
except ValueError as exc:
yield ExecutorError(message=str(exc), retryable=False)
return
app_session = await self._ensure_app_session(
state,
signature=signature,
effective_cwd=effective_cwd,
)
sandbox_mode = _sandbox_mode(self._os_env_spec)
if tools and sandbox_mode == "read-only":
sandbox_mode = "workspace-write"
try:
async for event in app_session.run_turn(
messages=messages,
tools=tools,
system_prompt=system_prompt,
model=model,
cwd=effective_cwd,
sandbox=sandbox_mode,
reasoning_effort=reasoning_effort,
):
yield event
except Exception as exc: # noqa: BLE001 — executor boundary converts any error into an ExecutorError event
yield ExecutorError(message=f"Codex executor error: {exc}")