From 495a0aad42df8ddcd1e1a36176c92c7dbf12dad2 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Tue, 14 Jul 2026 15:36:27 -0700 Subject: [PATCH 01/10] feat(codex): replace CLI adapter with SDK runtime Signed-off-by: Ajay Thorve --- .agents/skills/review-doc-style/SKILL.md | 2 +- ATTRIBUTIONS-Python.md | 20 + README.md | 6 +- adapters/codex-cli/README.md | 56 -- .../nemo_fabric_adapters/codex_cli/adapter.py | 604 ------------ adapters/codex-cli/uv.lock | 32 - adapters/{codex-cli => codex}/LICENSE | 0 adapters/codex/README.md | 142 +++ .../{codex-cli => codex}/fabric-adapter.json | 9 +- adapters/{codex-cli => codex}/pyproject.toml | 14 +- .../nemo_fabric_adapters/codex}/__init__.py | 2 +- .../src/nemo_fabric_adapters/codex/adapter.py | 901 ++++++++++++++++++ adapters/codex/uv.lock | 212 +++++ .../common/relay_hooks.py | 26 +- .../deepagents/adapter.py | 2 +- crates/fabric-core/src/config.rs | 4 +- crates/fabric-core/src/doctor.rs | 13 +- docs/getting-started/install.mdx | 20 +- docs/getting-started/overview.mdx | 4 +- docs/index.yml | 2 + docs/integrations/codex.mdx | 65 ++ docs/integrations/harbor.mdx | 4 +- docs/sdk/python.mdx | 4 +- examples/code_review_agent/README.md | 2 +- examples/code_review_agent/__init__.py | 4 +- examples/code_review_agent/__main__.py | 4 +- examples/code_review_agent/config.py | 13 +- examples/harbor/README.md | 4 +- examples/harbor/demo/README.md | 14 +- .../harbor/demo/task/environment/Dockerfile | 5 +- .../environment/fabric/configs/codex.yaml | 9 +- justfile | 2 +- pyproject.toml | 13 +- .../test_adapters_common_relay_hooks.py | 42 +- tests/adapters/test_codex_adapter.py | 559 +++++++++++ tests/adapters/test_codex_cli.py | 744 --------------- tests/e2e/test_codex.py | 119 +++ tests/e2e/test_codex_cli.py | 63 -- .../profiles/{codex-cli.yaml => codex.yaml} | 14 +- tests/integrations/test_harbor_runner.py | 50 +- tests/python/test_code_review_example.py | 10 +- uv.lock | 53 +- 42 files changed, 2219 insertions(+), 1649 deletions(-) delete mode 100644 adapters/codex-cli/README.md delete mode 100755 adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/adapter.py delete mode 100644 adapters/codex-cli/uv.lock rename adapters/{codex-cli => codex}/LICENSE (100%) create mode 100644 adapters/codex/README.md rename adapters/{codex-cli => codex}/fabric-adapter.json (69%) rename adapters/{codex-cli => codex}/pyproject.toml (69%) rename adapters/{codex-cli/src/nemo_fabric_adapters/codex_cli => codex/src/nemo_fabric_adapters/codex}/__init__.py (80%) create mode 100644 adapters/codex/src/nemo_fabric_adapters/codex/adapter.py create mode 100644 adapters/codex/uv.lock create mode 100644 docs/integrations/codex.mdx create mode 100644 tests/adapters/test_codex_adapter.py delete mode 100644 tests/adapters/test_codex_cli.py create mode 100644 tests/e2e/test_codex.py delete mode 100644 tests/e2e/test_codex_cli.py rename tests/fixtures/file-config-agent/profiles/{codex-cli.yaml => codex.yaml} (66%) diff --git a/.agents/skills/review-doc-style/SKILL.md b/.agents/skills/review-doc-style/SKILL.md index 81d79618c..fdfac5cd8 100644 --- a/.agents/skills/review-doc-style/SKILL.md +++ b/.agents/skills/review-doc-style/SKILL.md @@ -33,7 +33,7 @@ and NeMo Fabric repo conventions. - `README.md` - `docs/index.yml` - Package or crate READMEs - - Adapter and integration READMEs such as `adapters/codex-cli/README.md` or `integrations/harbor/README.md` + - Adapter and integration READMEs such as `adapters/codex/README.md` or `integrations/harbor/README.md` 4. Start with `assets/nvidia-style-guide.md`, then open only the focused support document needed for the issue under review. 5. Scan for high-signal style issues in headings, links, code formatting, terminology, procedures, and plain-English readability. 6. Report findings in severity order with file references and concrete rewrites. diff --git a/ATTRIBUTIONS-Python.md b/ATTRIBUTIONS-Python.md index bbbff62ca..12dd4e04a 100644 --- a/ATTRIBUTIONS-Python.md +++ b/ATTRIBUTIONS-Python.md @@ -6225,6 +6225,26 @@ Apache License limitations under the License. ``` +## openai-codex (0.1.0b3) + +### Licenses +License: `Apache-2.0` + + - `LICENSE`: +``` +(No license file found in locked artifact for openai-codex; see package metadata or PyPI.) +``` + +## openai-codex-cli-bin (0.137.0a4) + +### Licenses +License: `Apache-2.0` + + - `LICENSE`: +``` +(No license file found in locked artifact for openai-codex-cli-bin; see package metadata or PyPI.) +``` + ## orjson (3.11.9) ### Licenses diff --git a/README.md b/README.md index a04310d36..46821e709 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,8 @@ authentication, and execution details. harness, model, MCP, tools, skills, telemetry, or environment context without editing `agent.yaml`. - **Adapters:** harness-specific integrations selected by `harness.adapter_id`. - The Hermes adapter lives under `adapters/hermes/`; the Codex CLI - adapter lives under `adapters/codex-cli/`; the + The Hermes adapter lives under `adapters/hermes/`; the Codex SDK + adapter lives under `adapters/codex/`; the [Claude adapter](adapters/claude/README.md) lives under `adapters/claude/`; the LangChain Deep Agents adapter lives under `adapters/deepagents/`. Harness-specific extensions belong under @@ -179,7 +179,7 @@ the [Python SDK guide](docs/sdk/python.mdx). Exact signatures are in the [multi-harness demo](examples/harbor/demo/README.md): ownership, installation, and complete command matrices. - Adapter guides: [Hermes](adapters/hermes/README.md), - [Codex CLI](adapters/codex-cli/README.md), and + [Codex SDK](adapters/codex/README.md), and [Deep Agents](adapters/deepagents/README.md). ## Tests diff --git a/adapters/codex-cli/README.md b/adapters/codex-cli/README.md deleted file mode 100644 index 9b82f1cfe..000000000 --- a/adapters/codex-cli/README.md +++ /dev/null @@ -1,56 +0,0 @@ - - -# Codex CLI Adapter - -Runs an installed Codex CLI through Fabric's Python-adapter lifecycle. One -Fabric runtime maps to one Codex thread. - -Keep `fabric-adapter.json` aligned with the adapter implementation. -`contract_version` must match the adapter contract supported by Fabric core; -`adapter_id` is the stable id selected by `harness.adapter_id`. - -Install Fabric with the adapter dependency before running it: - -```bash -python3 -m pip install -e ".[runtime,codex]" -``` - -## Authentication and Codex Config - -The adapter does not read, copy, or rewrite Codex credentials. The child Codex -process inherits `HOME`, `CODEX_HOME`, platform runtime variables, and proxy or -certificate settings, so an existing `codex login` session remains -authoritative. Additional variables must be provided through -`harness.settings.env`. - -Codex continues to load its system, user, profile, and trusted project config. -Fabric adds only explicitly configured invocation overrides: - -- `models.default.model` selects `--model`; omit it to use Codex's configured - default. -- `environment.workspace` selects the process working directory. -- `harness.settings.sandbox` selects `read-only`, `workspace-write`, or - `danger-full-access`. -- `harness.settings.codex_profile` selects a Codex profile. -- `harness.settings.config_overrides` emits repeated `--config key=value` - arguments. Values may be TOML scalars or arrays; use dotted keys for nested - Codex settings. -- `harness.settings.codex_args` is an escape hatch for additional CLI flags. -- `harness.settings.timeout_seconds` bounds each invocation and defaults to 1800. - -`codex_command`, `codex_state_dir`, `cwd`, `env`, and -`skip_git_repo_check` are available for prepared environments and tests. - -## Execution Paths - -The first invocation records Codex's generated thread ID against the Fabric -runtime ID. Later invocations on the same runtime use -`codex exec resume `. Codex owns its transcript and authentication; -Fabric owns the runtime lifecycle and runtime-to-thread correlation record. -Both `fabric run` and stateful runtime paths accept text input. - -Use `codex_cli_config()` from `examples.code_review_agent` for local one-shot -and multi-turn examples. diff --git a/adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/adapter.py b/adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/adapter.py deleted file mode 100755 index 500fca23c..000000000 --- a/adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/adapter.py +++ /dev/null @@ -1,604 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Map Fabric runtime invocations onto ``codex exec``.""" - -from __future__ import annotations - -import hashlib -import json -import math -import os -import subprocess -import tomllib -from collections.abc import Mapping -from pathlib import Path -from typing import Any -from typing import NamedTuple - -import nemo_fabric_adapters.common.relay_gateway as relay_gateway -import nemo_fabric_adapters.common.relay_hooks as relay_hooks -import nemo_fabric_adapters.common.utils as common_utils -import tomli_w - -SANDBOXES = {"read-only", "workspace-write", "danger-full-access"} -DEFAULT_TIMEOUT_SECONDS = 1800 - - -class CodexRelaySettings(NamedTuple): - """Invocation-scoped Relay state consumed by the Codex adapter.""" - - gateway: relay_gateway.RelayGatewayLaunch - plugin_config: dict[str, Any] - - -class CodexSettings(NamedTuple): - telemetry_provider: str - codex_profile_name: str | None - codex_profile_path: Path | None - relay: CodexRelaySettings | None - - -def state_dir(payload: dict[str, Any]) -> Path: - settings = common_utils.settings_payload(payload) - config_root = Path(common_utils.config_root(payload)).resolve() - configured = settings.get("codex_state_dir") - if configured: - path = Path(str(configured)) - return path if path.is_absolute() else config_root / path - artifacts = common_utils.runtime_context(payload).get("artifacts") or {} - root = artifacts.get("root") or os.environ.get("FABRIC_ARTIFACTS") - if root: - return Path(str(root)).resolve() / ".fabric" / "codex-cli" - return config_root / "artifacts" / "codex-cli" / ".fabric" - - -def runtime_state_path(payload: dict[str, Any], runtime_id: str) -> Path: - key = hashlib.sha256(runtime_id.encode("utf-8")).hexdigest() - return state_dir(payload) / "runtimes" / f"{key}.json" - - -def load_thread_id(payload: dict[str, Any], runtime_id: str) -> str | None: - path = runtime_state_path(payload, runtime_id) - if not path.is_file(): - return None - try: - value = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as error: - raise RuntimeError(f"invalid Codex runtime state in {path}") from error - if ( - not isinstance(value, dict) - or value.get("runtime_id") != runtime_id - or not value.get("thread_id") - ): - raise RuntimeError(f"invalid Codex runtime state in {path}") - return str(value["thread_id"]) - - -def save_thread_id(payload: dict[str, Any], runtime_id: str, thread_id: str) -> None: - path = runtime_state_path(payload, runtime_id) - path.parent.mkdir(parents=True, exist_ok=True) - invocation_id = ( - common_utils.runtime_context(payload).get("invocation_id") or "pending" - ) - temporary = path.with_suffix(f".{invocation_id}.tmp") - temporary.write_text( - json.dumps({"runtime_id": runtime_id, "thread_id": thread_id}, indent=2), - encoding="utf-8", - ) - os.replace(temporary, path) - - -def selected_model(payload: dict[str, Any]) -> str | None: - settings = common_utils.settings_payload(payload) - models = common_utils.models_payload(payload) - model_config = models.get(settings.get("model", "default"), {}) - value = settings.get("model_name") - if not value and isinstance(model_config, dict): - value = model_config.get("model") - if not value: - return None - model = str(value) - return model.removeprefix("openai/") - - -def build_command( - payload: dict[str, Any], - *, - thread_id: str | None = None, - codex_settings: CodexSettings, -) -> list[str]: - settings = common_utils.settings_payload(payload) - command = resolve_command(payload, settings.get("codex_command") or "codex") - sandbox = str(settings.get("sandbox") or "read-only") - if sandbox not in SANDBOXES: - raise ValueError( - f"unsupported Codex sandbox {sandbox!r}; expected one of {sorted(SANDBOXES)}" - ) - - args = [command, "exec", "--json"] - - args.extend(["--sandbox", sandbox]) - - if codex_settings.codex_profile_name is not None: - args.extend(("--profile", codex_settings.codex_profile_name)) - - if codex_settings.relay is not None: - # By default Codex will not enable hooks for profiles that are not trusted until the user explicitly - # enables them. This is a problem for Fabric, because we want to be able to use hooks in a non-interactive - # way.So we add the --dangerously-bypass-hook-trust flag to bypass this check. - args.append("--dangerously-bypass-hook-trust") - - model = selected_model(payload) - if model: - args.extend(["--model", model]) - if settings.get("skip_git_repo_check", False): - args.append("--skip-git-repo-check") - args.extend(common_utils.normalize_list(settings.get("codex_args"))) - - if thread_id: - args.extend(["resume", thread_id, "-"]) - else: - args.append("-") - - return args - - -def config_overrides(settings: dict[str, Any]) -> Mapping[str, Any]: - overrides = settings.get("config_overrides") - if overrides is None: - return {} - if not isinstance(overrides, Mapping): - raise ValueError("config_overrides must be a mapping") - return overrides - - -def native_codex_telemetry_config(payload: dict[str, Any]) -> dict[str, Any]: - if "native" not in common_utils.telemetry_providers(payload): - return {} - - telemetry_config = common_utils.native_telemetry_config(payload) - components = telemetry_config.get("components") or [] - for component in components: - if ( - not isinstance(component, dict) - or component.get("kind") != "observability" - or not component.get("enabled", True) - ): - continue - component_config = component.get("config") or {} - opentelemetry = component_config.get("opentelemetry") or {} - if not isinstance(opentelemetry, dict) or not opentelemetry.get("enabled"): - continue - - otel: dict[str, Any] = {} - resource_attributes = opentelemetry.get("resource_attributes") or {} - environment = resource_attributes.get("deployment.environment") - if environment is not None: - otel["environment"] = environment - - endpoint = opentelemetry.get("endpoint") - if endpoint: - transport = opentelemetry.get("transport", "http_binary") - if transport == "http_binary": - exporter = "otlp-http" - protocol = "binary" - elif transport == "grpc": - exporter = "otlp-grpc" - protocol = "grpc" - elif transport == "http_json": - exporter = "otlp-http" - protocol = "json" - else: - raise ValueError( - f"unsupported Codex native OpenTelemetry transport {transport!r}" - ) - otel["trace_exporter"] = { - exporter: { - "endpoint": endpoint, - "protocol": protocol, - } - } - return {"otel": otel} - return {} - - -def apply_config_overrides( - config: dict[str, Any], - overrides: Mapping[str, Any], -) -> None: - for dotted_key, value in sorted(overrides.items()): - toml_value(value) - parts = str(dotted_key).split(".") - if any(not part for part in parts): - raise ValueError(f"invalid Codex config override key {dotted_key!r}") - target = config - for part in parts[:-1]: - existing = target.setdefault(part, {}) - if not isinstance(existing, dict): - raise ValueError( - f"Codex config override {dotted_key!r} conflicts with {part!r}" - ) - target = existing - target[parts[-1]] = value - - -def merge_config(config: dict[str, Any], layer: Mapping[str, Any]) -> None: - for key, value in layer.items(): - existing = config.get(key) - if isinstance(existing, dict) and isinstance(value, Mapping): - merge_config(existing, value) - else: - config[key] = value - - -def codex_home() -> Path: - configured = os.environ.get("CODEX_HOME") - if configured: - return Path(configured).expanduser() - return Path.home() / ".codex" - - -def load_codex_profile(settings: dict[str, Any]) -> dict[str, Any]: - profile = settings.get("codex_profile") - if not profile: - return {} - path = codex_home() / f"{profile}.toml" - with path.open("rb") as profile_file: - return tomllib.load(profile_file) - - -def write_config_files(payload: dict[str, Any]) -> CodexSettings: - settings = common_utils.settings_payload(payload) - telemetry_providers = common_utils.telemetry_providers(payload) - telemetry_provider = telemetry_providers[0] if telemetry_providers else "relay" - relay_enabled = common_utils.relay_enabled(payload) - overrides = config_overrides(settings) - config = load_codex_profile(settings) - if "native" in telemetry_providers: - merge_config(config, native_codex_telemetry_config(payload)) - - codex_profile_name = None - codex_profile_path = None - relay = None - if relay_enabled or bool(config) or bool(overrides): - codex_profile_name, codex_profile_path = get_codex_profile_path(payload) - - if relay_enabled: - relay_gateway_port = relay_gateway.find_available_tcp_port() - relay_gateway_bind = f"127.0.0.1:{relay_gateway_port}" - relay_gateway_url = f"http://{relay_gateway_bind}" - - # nemo-relay infers the plugin config location from the relay config. - relay_plugin_config = common_utils.load_relay_plugin_config(payload) - relay_executable = relay_gateway.resolve_relay_command( - Path(common_utils.config_root(payload)).resolve(), - settings.get("nemo_relay_command") or "nemo-relay", - ) - relay_config_path, _ = common_utils.write_relay_configs( - relay_config={"agents": {"codex": {"command": "codex"}}}, - plugin_config=relay_plugin_config, - observability_version=relay_gateway.relay_cli_observability_version( - relay_executable - ), - ) - if relay_config_path is None: - raise RuntimeError( - "NeMo Relay configuration did not produce a gateway config" - ) - - gateway = relay_gateway.RelayGatewayLaunch( - executable=relay_executable, - config_path=relay_config_path, - bind=relay_gateway_bind, - url=relay_gateway_url, - log_path=relay_config_path.parent / "gateway.log", - ) - relay = CodexRelaySettings( - gateway=gateway, - plugin_config=relay_plugin_config, - ) - - merge_config( - config, - { - "model_provider": "nemo-relay-openai", - "model_providers": { - "nemo-relay-openai": { - "name": "NeMo Relay OpenAI", - "base_url": relay_gateway_url, - "wire_api": "responses", - "requires_openai_auth": True, - "supports_websockets": False, - } - }, - "features": {"hooks": True}, - "hooks": relay_hooks.render_relay_hooks("codex", relay_executable)[ - "hooks" - ], - }, - ) - - apply_config_overrides(config, overrides) - codex_profile_path.parent.mkdir(parents=True, exist_ok=True) - codex_profile_path.write_text( - tomli_w.dumps(config), - encoding="utf-8", - ) - - return CodexSettings( - telemetry_provider=telemetry_provider, - codex_profile_name=codex_profile_name, - codex_profile_path=codex_profile_path, - relay=relay, - ) - - -def get_codex_profile_path(payload: dict[str, Any]) -> tuple[str, Path]: - runtime_id = common_utils.runtime_context(payload).get("runtime_id") - if not runtime_id: - raise RuntimeError( - "runtime_context.runtime_id is required for generated Codex profiles" - ) - - name = f"fabric-{runtime_id}" - return name, codex_home() / f"{name}.config.toml" - - -def resolve_command(payload: dict[str, Any], value: Any) -> str: - command = Path(str(value)) - if command.is_absolute() or len(command.parts) == 1: - return str(command) - config_root = Path(common_utils.config_root(payload)).resolve() - return str((config_root / command).resolve()) - - -def toml_value(value: Any) -> str: - pending = [value] - while pending: - item = pending.pop() - if isinstance(item, float) and not math.isfinite(item): - raise ValueError("Codex config overrides require finite numbers") - if isinstance(item, Mapping): - pending.extend(item.values()) - elif isinstance(item, (list, tuple)): - pending.extend(item) - try: - document = tomli_w.dumps({"value": value}) - except TypeError as error: - raise ValueError( - "Codex config override values must be a TOML scalar or array" - ) from error - prefix = "value = " - if not document.startswith(prefix): - raise ValueError("Codex config override values must be a TOML scalar or array") - return document.removeprefix(prefix).rstrip() - - -def parse_events(contents: str) -> dict[str, Any]: - events: list[dict[str, Any]] = [] - thread_id = None - response = None - usage = None - error = None - for line in contents.splitlines(): - if not line.strip(): - continue - try: - event = json.loads(line) - except json.JSONDecodeError: - continue - if not isinstance(event, dict): - continue - events.append(event) - event_type = event.get("type") - if event_type == "thread.started": - thread_id = event.get("thread_id") - elif event_type == "item.completed": - item = event.get("item") or {} - if item.get("type") == "agent_message": - response = item.get("text") - elif event_type == "turn.completed": - usage = event.get("usage") - elif event_type in {"turn.failed", "error"}: - failure = event.get("error") or event.get("message") or event - error = ( - failure.get("message") if isinstance(failure, dict) else str(failure) - ) - return { - "events": events, - "thread_id": str(thread_id) if thread_id else None, - "response": response, - "usage": usage, - "error": error, - } - - -def request_to_prompt(payload: dict[str, Any]) -> str: - value = (payload.get("request") or {}).get("input", "") - if not isinstance(value, str): - raise ValueError("Codex CLI adapter requires text input") - return value - - -def resolve_cwd(payload: dict[str, Any]) -> Path: - settings = common_utils.settings_payload(payload) - environment = common_utils.environment_payload(payload) - config_root = Path(common_utils.config_root(payload)).resolve() - path = Path(str(settings.get("cwd") or environment.get("workspace") or ".")) - return path.resolve() if path.is_absolute() else (config_root / path).resolve() - - -def build_env( - payload: dict[str, Any], - *, - relay_gateway_url: str | None = None, -) -> dict[str, str]: - env = common_utils.virtualenv_subprocess_env() - - configured = common_utils.settings_payload(payload).get("env") - if configured is None: - configured = {} - - if not isinstance(configured, Mapping): - raise ValueError("env must be a mapping of variable names to values") - - env.update({str(key): str(value) for key, value in configured.items()}) - - if relay_gateway_url is not None: - env["NEMO_RELAY_GATEWAY_URL"] = relay_gateway_url - - return env - - -def process_timeout(payload: dict[str, Any]) -> float: - value = common_utils.settings_payload(payload).get( - "timeout_seconds", DEFAULT_TIMEOUT_SECONDS - ) - if ( - isinstance(value, bool) - or not isinstance(value, (int, float)) - or not math.isfinite(value) - or value <= 0 - ): - raise ValueError("timeout_seconds must be a positive finite number") - return float(value) - - -def exception_output(value: str | bytes | None) -> str: - if value is None: - return "" - return value.decode(errors="replace") if isinstance(value, bytes) else value - - -def run_codex(payload: dict[str, Any]) -> dict[str, Any]: - runtime_id = common_utils.runtime_id(payload) - prior_thread_id = load_thread_id(payload, runtime_id) - cwd = resolve_cwd(payload) - codex_settings = write_config_files(payload) - relay_gateway_process = None - - try: - if codex_settings.relay is not None: - relay_gateway_process = relay_gateway.start_relay_gateway( - launch=codex_settings.relay.gateway, - cwd=cwd, - ) - - command = build_command( - payload, - thread_id=prior_thread_id, - codex_settings=codex_settings, - ) - - timeout = process_timeout(payload) - launch_error = None - try: - completed = subprocess.run( - command, - cwd=cwd, - env=build_env( - payload, - relay_gateway_url=( - codex_settings.relay.gateway.url - if codex_settings.relay is not None - else None - ), - ), - input=request_to_prompt(payload), - text=True, - capture_output=True, - check=False, - timeout=timeout, - ) - except subprocess.TimeoutExpired as error: - completed = subprocess.CompletedProcess( - command, - 124, - exception_output(error.stdout), - exception_output(error.stderr), - ) - launch_error = f"Codex CLI timed out after {timeout:g} seconds" - except OSError as error: - completed = subprocess.CompletedProcess(command, 127, "", str(error)) - launch_error = f"Codex CLI could not start: {error}" - finally: - if codex_settings.codex_profile_path is not None: - codex_settings.codex_profile_path.unlink(missing_ok=True) - - if relay_gateway_process is not None: - relay_gateway.stop_relay_gateway(relay_gateway_process) - - parsed = parse_events(completed.stdout) - thread_id = parsed["thread_id"] or prior_thread_id - error = launch_error or parsed["error"] - if completed.returncode != 0: - error = ( - error - or completed.stderr.strip() - or "Codex CLI exited with a non-zero status" - ) - if parsed["response"] is None: - error = error or "Codex invocation did not return a final agent message" - if not thread_id: - error = error or "Codex runtime invocation did not return a thread identity" - if prior_thread_id and thread_id != prior_thread_id: - error = error or ( - f"Codex resumed thread {thread_id}, expected persisted thread {prior_thread_id}" - ) - if thread_id and not error: - save_thread_id(payload, runtime_id, thread_id) - - output = { - "harness": "codex", - "adapter": "cli", - "mode": "codex_cli_runtime", - "command": redact_command(command), - "cwd": str(cwd), - "model": selected_model(payload), - "thread_id": thread_id, - "response": parsed["response"], - "usage": parsed["usage"], - "returncode": completed.returncode, - "error": error, - "failed": error is not None, - "state_dir": str(state_dir(payload)), - } - - if codex_settings.relay is not None: - relay_artifacts = common_utils.collect_relay_artifacts( - codex_settings.relay.plugin_config - ) - output["relay_runtime"] = { - "enabled": True, - "config_path": os.environ.get("FABRIC_RELAY_CONFIG_PATH"), - "emitter": "nemo-relay", - "gateway_config_path": str(codex_settings.relay.gateway.config_path), - "gateway_log_path": str(codex_settings.relay.gateway.log_path), - } - output["relay_artifacts"] = relay_artifacts - - return output - - -def redact_command(command: list[str]) -> list[str]: - redacted = list(command) - for index, value in enumerate(redacted[:-1]): - if value == "--config" and any( - marker in redacted[index + 1].lower() - for marker in ("key", "token", "secret", "password") - ): - redacted[index + 1] = "" - return redacted - - -def main() -> None: - output = run_codex(common_utils.load_payload()) - print(json.dumps(output, sort_keys=True)) - if output["failed"]: - raise SystemExit(2) - - -if __name__ == "__main__": - main() diff --git a/adapters/codex-cli/uv.lock b/adapters/codex-cli/uv.lock deleted file mode 100644 index 482a014c0..000000000 --- a/adapters/codex-cli/uv.lock +++ /dev/null @@ -1,32 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" - -[[package]] -name = "nemo-fabric-adapters-codex-cli" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "nemo-fabric-adapters-common" }, - { name = "tomli-w" }, -] - -[package.metadata] -requires-dist = [ - { name = "nemo-fabric-adapters-common", editable = "../common" }, - { name = "tomli-w", specifier = "~=1.2" }, -] - -[[package]] -name = "nemo-fabric-adapters-common" -version = "0.1.0" -source = { editable = "../common" } - -[[package]] -name = "tomli-w" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, -] diff --git a/adapters/codex-cli/LICENSE b/adapters/codex/LICENSE similarity index 100% rename from adapters/codex-cli/LICENSE rename to adapters/codex/LICENSE diff --git a/adapters/codex/README.md b/adapters/codex/README.md new file mode 100644 index 000000000..d8f66f8dd --- /dev/null +++ b/adapters/codex/README.md @@ -0,0 +1,142 @@ + + +# Codex Adapter + +The `nvidia.fabric.codex` adapter uses the official Codex Python SDK behind +Fabric's normalized invocation contract. It does not resolve or execute a +separately installed `codex` command. The SDK package owns its pinned +app-server runtime and typed JSON-RPC protocol. + +## Install + +```bash +just wheels +python -m pip install --find-links dist "nemo-fabric[codex]" +``` + +## Authentication + +Fabric reuses the authentication state that Codex stores under `CODEX_HOME` +(default: `~/.codex`). Fabric does not perform an interactive login, copy +credentials, or mutate the user's Codex configuration. + +Codex supports two OpenAI authentication modes: + +- **ChatGPT login:** Sign in through Codex with a ChatGPT plan. Fabric can then + run without `OPENAI_API_KEY` while that cached login remains valid. +- **API key login:** Provision the same Codex credential store with an OpenAI + API key. This mode uses OpenAI Platform billing rather than ChatGPT plan + credits. + +For a nondefault credential store, set `CODEX_HOME` before both login and the +Fabric invocation. Treat `CODEX_HOME/auth.json` as a secret when Codex uses +file-based credential storage. Refer to the +[Codex authentication documentation](https://developers.openai.com/codex/auth/) +for login, headless setup, and credential-storage options. + +The adapter forwards `OPENAI_API_KEY` and a selected model's `api_key_env` to +the SDK runtime. The current real-agent acceptance path validates an existing +Codex login; it does not yet claim a raw environment variable as a complete +login flow. + +The dependency graph includes `openai-codex-cli-bin`. The Codex SDK owns this +pinned app-server distribution; Fabric does not treat it as a user-installed +command or an adapter descriptor requirement. + +## Execution Model + +Each Fabric invocation starts a fresh SDK client and closes its app-server +transport before returning. The first invocation creates a Codex thread and +persists its ID under the Fabric artifact root. Later invocations for the same +Fabric runtime resume that exact thread. Codex owns the transcript; Fabric owns +runtime-to-thread correlation, timeout, cancellation, and cleanup. + +The result includes the SDK's typed terminal response, turn status, token +usage, timing, and completed thread items. It does not expose CLI commands, +return codes, stdout, or stderr. + +## Configuration + +Use normalized `FabricConfig` fields for portable configuration: + +- `models` selects the Codex model. The adapter requires and explicitly selects + the built-in `openai` provider. +- `environment.workspace` sets the working directory. +- `telemetry` enables native OpenTelemetry or NeMo Relay observability. + +Codex-specific controls belong in `harness.settings`: + +- `sandbox`: `read-only`, `workspace-write`, or `danger-full-access` +- `approval_mode`: `auto_review` or `deny_all` +- `base_instructions` and `developer_instructions` +- `personality`, `reasoning_effort`, `service_name`, and `service_tier` +- `output_schema` for SDK-native structured output +- `config_overrides` as dotted request-scoped Codex configuration keys +- `timeout_seconds`, defaulting to 1800 +- `env` for variables explicitly forwarded to the Codex runtime +- `nemo_relay_command` for the optional external Relay gateway executable + +The removed CLI settings `codex_command`, `codex_args`, `codex_profile`, +`codex_state_dir`, and `skip_git_repo_check` are errors. `model_name` and `cwd` +must use the normalized model and environment fields. + +The adapter filters the inherited environment. It retains portable OS and +Codex state variables, the selected model's `api_key_env`, and explicit +`settings.env` values while clearing unrelated parent-process secrets. + +## Relay Observability + +Enable Relay through Fabric's normalized telemetry configuration. For each +Relay-enabled invocation, Fabric: + +1. Resolves one external `nemo-relay` executable. +2. Generates invocation-scoped gateway and plugin configuration. +3. Starts and health-checks `nemo-relay --config ... --bind ...`. +4. Redirects the built-in OpenAI provider with request-scoped + `openai_base_url` and passes Relay hooks through the Codex SDK's `config` + argument. +5. Interrupts timed-out turns, closes the SDK runtime, and stops the gateway. + +The SDK remains the Codex execution driver. Relay is a supervised sidecar and +hook forwarder; the adapter never invokes a `nemo-relay codex` wrapper. The +result reports the gateway config, URL, log, and collected Relay artifacts. + +Fabric deliberately keeps Codex on its reserved built-in `openai` provider. +Defining Relay as a custom model-provider alias breaks the Python SDK's +ChatGPT-authenticated request path. Redirecting only `openai_base_url` preserves +the SDK's supported authentication and host metadata while allowing Relay to +capture Responses traffic. Fabric does not spoof the Codex CLI identity or fall +back to CLI execution. Relay routes and observes requests; it does not provide +OpenAI credentials or change the selected Codex authentication mode. + +Relay-enabled runs require the external `nemo-relay` CLI in addition to the +Python package dependencies: + +```bash +cargo install nemo-relay-cli +``` + +The `nemo-relay` Python package does not install this executable. Refer to the +[NeMo Relay installation guide](https://docs.nvidia.com/nemo/relay/getting-started/installation) +for other supported installation methods. + +## Local Validation + +Run the unit and opt-in real SDK tests separately: + +```bash +uv run pytest tests/adapters/test_codex_adapter.py -q +RUN_FABRIC_CODEX_INTEGRATION=1 uv run pytest tests/e2e/test_codex.py -q +RUN_FABRIC_CODEX_RELAY_INTEGRATION=1 \ + FABRIC_TEST_NEMO_RELAY_COMMAND=/path/to/nemo-relay \ + uv run pytest tests/e2e/test_codex.py -q +``` + +The SDK test uses the current Codex authentication state and exercises both a +one-shot invocation and multi-turn thread resume. The Relay test additionally +requires an external gateway binary and verifies one-shot and resumed model +responses, stable thread identity, ATOF, and ATIF; gateway startup alone is not +a passing result. diff --git a/adapters/codex-cli/fabric-adapter.json b/adapters/codex/fabric-adapter.json similarity index 69% rename from adapters/codex-cli/fabric-adapter.json rename to adapters/codex/fabric-adapter.json index e5d7045d4..9cbc7f147 100644 --- a/adapters/codex-cli/fabric-adapter.json +++ b/adapters/codex/fabric-adapter.json @@ -1,14 +1,11 @@ { "contract_version": "fabric.adapter/v1alpha1", - "adapter_id": "nvidia.fabric.codex.cli", + "adapter_id": "nvidia.fabric.codex", "harness": "codex", "adapter_kind": "python", "runner": { - "module": "nemo_fabric_adapters.codex_cli.adapter", - "callable": "run_codex" - }, - "requirements": { - "binaries": ["codex"] + "module": "nemo_fabric_adapters.codex.adapter", + "callable": "run" }, "config": { "accepts": ["models", "telemetry"] diff --git a/adapters/codex-cli/pyproject.toml b/adapters/codex/pyproject.toml similarity index 69% rename from adapters/codex-cli/pyproject.toml rename to adapters/codex/pyproject.toml index 540793499..693f69727 100644 --- a/adapters/codex-cli/pyproject.toml +++ b/adapters/codex/pyproject.toml @@ -8,9 +8,9 @@ requires = [ build-backend = "setuptools.build_meta" [project] -name = "nemo-fabric-adapters-codex-cli" +name = "nemo-fabric-adapters-codex" version = "0.1.0" -description = "Codex CLI adapter for NeMo Fabric" +description = "Codex SDK adapter for NeMo Fabric" authors = [ { name = "NVIDIA Corporation" }, ] @@ -26,6 +26,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "nemo-fabric-adapters-common == 0.1.0", + "openai-codex==0.1.0b3", "tomli-w~=1.2", ] @@ -35,10 +36,15 @@ Documentation = "https://nvidia-nemo-fabric.docs.buildwithfern.com/nemo/fabric" [tool.setuptools.packages.find] where = ["src"] -include = ["nemo_fabric_adapters.codex_cli*"] +include = ["nemo_fabric_adapters.codex*"] [tool.setuptools.data-files] -"share/nemo-fabric/adapters/codex-cli" = ["fabric-adapter.json"] +"share/nemo-fabric/adapters/codex" = ["fabric-adapter.json"] [tool.uv.sources] nemo-fabric-adapters-common = { path = "../common", editable = true } + +[tool.uv] +# The SDK pins this prerelease runtime. Constraining that transitive package +# lets uv resolve it without enabling prereleases for unrelated dependencies. +constraint-dependencies = ["openai-codex-cli-bin==0.137.0a4"] diff --git a/adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/__init__.py b/adapters/codex/src/nemo_fabric_adapters/codex/__init__.py similarity index 80% rename from adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/__init__.py rename to adapters/codex/src/nemo_fabric_adapters/codex/__init__.py index 70338898a..098d7aca6 100644 --- a/adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/__init__.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/__init__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Codex CLI adapter package.""" +"""Codex SDK adapter package.""" diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py new file mode 100644 index 000000000..737f2c68a --- /dev/null +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -0,0 +1,901 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Run Codex through its native Python SDK and the Fabric adapter contract.""" + +from __future__ import annotations + +import asyncio +import json +import math +import os +from dataclasses import asdict, dataclass, is_dataclass +from enum import Enum +from hashlib import sha256 +from pathlib import Path +from typing import Any + +from openai_codex import ( + ApprovalMode, + AsyncCodex, + CodexConfig, + CodexError, + Sandbox, + TransportClosedError, + is_retryable_error, +) +from openai_codex.types import Personality, ReasoningEffort, TurnStatus + +import nemo_fabric_adapters.common.relay_gateway as relay_gateway +import nemo_fabric_adapters.common.relay_hooks as relay_hooks +import nemo_fabric_adapters.common.utils as common_utils + + +DEFAULT_TIMEOUT_SECONDS = 1800.0 +INTERRUPT_TIMEOUT_SECONDS = 5.0 +SANDBOXES = { + "read-only": Sandbox.read_only, + "workspace-write": Sandbox.workspace_write, + "danger-full-access": Sandbox.full_access, +} +APPROVAL_MODES = { + "auto_review": ApprovalMode.auto_review, + "deny_all": ApprovalMode.deny_all, +} +INHERITED_ENV_NAMES = { + "APPDATA", + "CODEX_HOME", + "CODEX_SQLITE_HOME", + "COMSPEC", + "HOME", + "HTTP_PROXY", + "HTTPS_PROXY", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LOCALAPPDATA", + "NO_PROXY", + "OPENAI_API_KEY", + "PATH", + "PATHEXT", + "SHELL", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "SYSTEMROOT", + "TEMP", + "TMP", + "TMPDIR", + "USERPROFILE", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "http_proxy", + "https_proxy", + "no_proxy", +} +REMOVED_CLI_SETTINGS = { + "codex_args", + "codex_command", + "codex_profile", + "codex_state_dir", + "skip_git_repo_check", +} +NORMALIZED_SETTING_FIELDS = { + "cwd": "FabricConfig.environment.workspace", + "model_name": "FabricConfig.models", +} + + +@dataclass(frozen=True) +class CodexRelaySettings: + """Invocation-scoped Relay state consumed by the Codex SDK adapter.""" + + gateway: relay_gateway.RelayGatewayLaunch + plugin_config: dict[str, Any] + + +class CodexAdapterError(Exception): + """Expected adapter error with a stable public code.""" + + def __init__( + self, + code: str, + message: str, + *, + metadata: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.metadata = metadata or {} + + +class AdapterInputError(CodexAdapterError): + """Invalid Fabric invocation input.""" + + +class AdapterConfigError(CodexAdapterError): + """Invalid Codex adapter configuration.""" + + +class AdapterStateError(CodexAdapterError): + """Invalid persisted runtime state.""" + + +class AdapterRelayError(CodexAdapterError): + """NeMo Relay setup or lifecycle failure.""" + + +def _mapping(value: Any, *, name: str) -> dict[str, Any]: + if value is None: + return {} + if not isinstance(value, dict): + raise AdapterConfigError( + "codex_invalid_configuration", f"{name} must be a mapping" + ) + return value + + +def _settings(payload: dict[str, Any]) -> dict[str, Any]: + return _mapping(common_utils.settings_payload(payload), name="harness.settings") + + +def _validate_settings_boundary(settings: dict[str, Any]) -> None: + removed = sorted(REMOVED_CLI_SETTINGS.intersection(settings)) + if removed: + names = ", ".join(f"harness.settings.{name}" for name in removed) + raise AdapterConfigError( + "codex_invalid_configuration", + f"Codex CLI-only settings are not supported by the SDK adapter: {names}", + ) + for name, normalized_field in NORMALIZED_SETTING_FIELDS.items(): + if name in settings: + raise AdapterConfigError( + "codex_invalid_configuration", + f"harness.settings.{name} is not supported; use {normalized_field}", + ) + + +def runtime_id(payload: dict[str, Any]) -> str: + value = common_utils.runtime_context(payload).get("runtime_id") + if not isinstance(value, str) or not value: + raise AdapterInputError( + "codex_invalid_request", "Fabric runtime ID is required" + ) + return value + + +def request_prompt(payload: dict[str, Any]) -> str: + value = (payload.get("request") or {}).get("input") + if not isinstance(value, str): + raise AdapterInputError("codex_invalid_request", "Codex input must be text") + return value + + +def resolve_cwd(payload: dict[str, Any]) -> Path: + environment = _mapping( + common_utils.environment_payload(payload), name="runtime environment" + ) + value = environment.get("workspace") or common_utils.config_root(payload) + path = Path(str(value)) + if not path.is_absolute(): + path = Path(common_utils.config_root(payload)) / path + return path.resolve() + + +def _selected_model_config(payload: dict[str, Any]) -> dict[str, Any]: + settings = _settings(payload) + models = _mapping(common_utils.models_payload(payload), name="models") + selected = models.get(settings.get("model", "default")) or {} + return _mapping(selected, name="selected model") + + +def selected_model(payload: dict[str, Any]) -> str | None: + model_config = _selected_model_config(payload) + value = model_config.get("model") + if value is None: + return None + if model_config.get("provider") != "openai": + raise AdapterConfigError( + "codex_invalid_configuration", + "selected model provider must be openai for the Codex adapter", + ) + if not isinstance(value, str) or not value: + raise AdapterConfigError( + "codex_invalid_configuration", "model must be a non-empty string" + ) + return value.removeprefix("openai/") + + +def sandbox(payload: dict[str, Any]) -> Sandbox: + value = _settings(payload).get("sandbox", "read-only") + try: + return SANDBOXES[value] + except (KeyError, TypeError) as error: + raise AdapterConfigError( + "codex_invalid_configuration", + f"sandbox must be one of: {', '.join(sorted(SANDBOXES))}", + ) from error + + +def approval_mode(payload: dict[str, Any]) -> ApprovalMode: + value = _settings(payload).get("approval_mode", "auto_review") + try: + return APPROVAL_MODES[value] + except (KeyError, TypeError) as error: + raise AdapterConfigError( + "codex_invalid_configuration", + f"approval_mode must be one of: {', '.join(sorted(APPROVAL_MODES))}", + ) from error + + +def timeout_seconds(payload: dict[str, Any]) -> float: + value = _settings(payload).get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise AdapterConfigError( + "codex_invalid_configuration", "timeout_seconds must be positive" + ) + result = float(value) + if result <= 0 or not math.isfinite(result): + raise AdapterConfigError( + "codex_invalid_configuration", "timeout_seconds must be positive" + ) + return result + + +def _optional_string(settings: dict[str, Any], name: str) -> str | None: + value = settings.get(name) + if value is None: + return None + if not isinstance(value, str) or not value: + raise AdapterConfigError( + "codex_invalid_configuration", + f"harness.settings.{name} must be a non-empty string", + ) + return value + + +def child_environment( + payload: dict[str, Any], *, relay_gateway_url: str | None = None +) -> dict[str, str]: + values = {name: "" for name in os.environ} + values.update( + {name: value for name in INHERITED_ENV_NAMES if (value := os.environ.get(name))} + ) + model_config = _selected_model_config(payload) + api_key_env = model_config.get("api_key_env") + if isinstance(api_key_env, str) and api_key_env in os.environ: + values[api_key_env] = os.environ[api_key_env] + configured = _mapping(_settings(payload).get("env"), name="harness.settings.env") + if any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in configured.items() + ): + raise AdapterConfigError( + "codex_invalid_configuration", + "harness.settings.env must contain strings", + ) + values.update(configured) + # The SDK overlays this mapping on the parent environment. An empty + # originator is still treated as an override by Codex and produces invalid + # initialize metadata ("/"). Use the official SDK client identity + # without inheriting the identity of a parent Codex process. + values["CODEX_INTERNAL_ORIGINATOR_OVERRIDE"] = "codex_python_sdk" + if relay_gateway_url is not None: + values["NEMO_RELAY_GATEWAY_URL"] = relay_gateway_url + return values + + +def _artifact_root(payload: dict[str, Any]) -> Path: + artifacts = common_utils.runtime_context(payload).get("artifacts") or {} + root = artifacts.get("root") if isinstance(artifacts, dict) else None + if root: + return Path(str(root)) + return Path(common_utils.config_root(payload)) / "artifacts" / "codex" + + +def state_dir(payload: dict[str, Any]) -> Path: + return _artifact_root(payload) / ".fabric" / "codex" + + +def runtime_state_path(payload: dict[str, Any], fabric_runtime_id: str) -> Path: + digest = sha256(fabric_runtime_id.encode("utf-8")).hexdigest() + return state_dir(payload) / "runtimes" / f"{digest}.json" + + +def load_thread_id(payload: dict[str, Any], fabric_runtime_id: str) -> str | None: + path = runtime_state_path(payload, fabric_runtime_id) + if not path.exists(): + return None + try: + state = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(state, dict): + raise ValueError("state must be an object") + if state.get("runtime_id") != fabric_runtime_id: + raise ValueError("runtime mismatch") + thread_id = state.get("codex_thread_id") + if not isinstance(thread_id, str) or not thread_id: + raise ValueError("missing Codex thread") + return thread_id + except (OSError, ValueError, json.JSONDecodeError) as error: + raise AdapterStateError( + "codex_invalid_runtime_state", "Codex runtime state is invalid" + ) from error + + +def save_thread_id( + payload: dict[str, Any], fabric_runtime_id: str, codex_thread_id: str +) -> None: + if not codex_thread_id: + raise AdapterStateError( + "codex_invalid_runtime_state", "Codex thread ID is missing" + ) + path = runtime_state_path(payload, fabric_runtime_id) + path.parent.mkdir(parents=True, exist_ok=True) + invocation_id = ( + common_utils.runtime_context(payload).get("invocation_id") or "invocation" + ) + temporary = path.with_suffix(f".{invocation_id}.tmp") + temporary.write_text( + json.dumps( + {"runtime_id": fabric_runtime_id, "codex_thread_id": codex_thread_id}, + sort_keys=True, + ), + encoding="utf-8", + ) + os.replace(temporary, path) + + +def _merge_config(target: dict[str, Any], layer: dict[str, Any]) -> None: + for key, value in layer.items(): + existing = target.get(key) + if isinstance(existing, dict) and isinstance(value, dict): + _merge_config(existing, value) + else: + target[key] = value + + +def _json_value(value: Any, *, name: str) -> Any: + try: + json.dumps(value, allow_nan=False) + except (TypeError, ValueError) as error: + raise AdapterConfigError( + "codex_invalid_configuration", f"{name} must be JSON-compatible" + ) from error + return value + + +def _apply_config_overrides( + config: dict[str, Any], overrides: dict[str, Any] +) -> None: + for dotted_key, value in sorted(overrides.items()): + if not isinstance(dotted_key, str): + raise AdapterConfigError( + "codex_invalid_configuration", + "config_overrides keys must be strings", + ) + parts = dotted_key.split(".") + if any(not part for part in parts): + raise AdapterConfigError( + "codex_invalid_configuration", + f"invalid Codex config override key {dotted_key!r}", + ) + target = config + for part in parts[:-1]: + existing = target.setdefault(part, {}) + if not isinstance(existing, dict): + raise AdapterConfigError( + "codex_invalid_configuration", + f"Codex config override {dotted_key!r} conflicts with {part!r}", + ) + target = existing + target[parts[-1]] = _json_value( + value, name=f"config_overrides.{dotted_key}" + ) + + +def native_codex_telemetry_config(payload: dict[str, Any]) -> dict[str, Any]: + if "native" not in common_utils.telemetry_providers(payload): + return {} + + telemetry_config = common_utils.native_telemetry_config(payload) + for component in telemetry_config.get("components") or []: + if ( + not isinstance(component, dict) + or component.get("kind") != "observability" + or not component.get("enabled", True) + ): + continue + component_config = component.get("config") or {} + opentelemetry = component_config.get("opentelemetry") or {} + if not isinstance(opentelemetry, dict) or not opentelemetry.get("enabled"): + continue + + otel: dict[str, Any] = {} + resource_attributes = opentelemetry.get("resource_attributes") or {} + environment = resource_attributes.get("deployment.environment") + if environment is not None: + otel["environment"] = environment + + endpoint = opentelemetry.get("endpoint") + if endpoint: + transport = opentelemetry.get("transport", "http_binary") + exporters = { + "http_binary": ("otlp-http", "binary"), + "grpc": ("otlp-grpc", "grpc"), + "http_json": ("otlp-http", "json"), + } + try: + exporter, protocol = exporters[transport] + except (KeyError, TypeError) as error: + raise AdapterConfigError( + "codex_invalid_configuration", + f"unsupported Codex native OpenTelemetry transport {transport!r}", + ) from error + otel["trace_exporter"] = { + exporter: {"endpoint": endpoint, "protocol": protocol} + } + return {"otel": otel} + return {} + + +def prepare_codex_relay(payload: dict[str, Any]) -> CodexRelaySettings | None: + """Generate invocation-scoped Relay gateway configuration.""" + + if not common_utils.relay_enabled(payload): + return None + command = _settings(payload).get("nemo_relay_command") or "nemo-relay" + if not isinstance(command, (str, Path)): + raise AdapterConfigError( + "codex_invalid_configuration", "nemo_relay_command must be a path" + ) + try: + executable = relay_gateway.resolve_relay_command( + Path(common_utils.config_root(payload)).resolve(), command + ) + except FileNotFoundError as error: + raise AdapterRelayError( + "codex_relay_unavailable", "NeMo Relay CLI executable was not found" + ) from error + + try: + observability_version = relay_gateway.relay_cli_observability_version( + executable + ) + plugin_config = common_utils.load_relay_plugin_config(payload) + config_path, plugin_config_path = common_utils.write_relay_configs( + # The SDK owns Codex execution. Relay needs only gateway defaults and + # the sibling plugins.toml; configuring an agent command would retain + # a misleading dependency on the removed Codex CLI launch path. + relay_config={}, + plugin_config=plugin_config, + observability_version=observability_version, + ) + except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as error: + raise AdapterRelayError( + "codex_relay_configuration_failed", + "NeMo Relay runtime configuration is unavailable", + ) from error + if config_path is None or plugin_config_path is None: + raise AdapterRelayError( + "codex_relay_configuration_failed", + "NeMo Relay runtime configuration is unavailable", + ) + + port = relay_gateway.find_available_tcp_port() + bind = f"127.0.0.1:{port}" + return CodexRelaySettings( + gateway=relay_gateway.RelayGatewayLaunch( + executable=executable, + config_path=config_path, + bind=bind, + url=f"http://{bind}", + log_path=config_path.parent / "gateway.log", + ), + plugin_config=plugin_config, + ) + + +def thread_config( + payload: dict[str, Any], relay: CodexRelaySettings | None +) -> dict[str, Any]: + """Build request-scoped Codex config without writing a user profile.""" + + config = native_codex_telemetry_config(payload) + overrides = _mapping( + _settings(payload).get("config_overrides"), + name="harness.settings.config_overrides", + ) + _apply_config_overrides(config, overrides) + if relay is not None: + _merge_config( + config, + { + # Keep Codex's reserved built-in provider so the SDK preserves + # its supported ChatGPT/API-key auth and host metadata paths. + # Relay is an OpenAI transport proxy, not a distinct model + # provider, so only redirect the built-in provider's base URL. + "openai_base_url": relay.gateway.url, + "features": {"hooks": True}, + "hooks": relay_hooks.render_relay_hooks( + "codex", relay.gateway.executable + )["hooks"], + # This runtime-only request override is the SDK-native equivalent + # of the former non-interactive CLI flag. Fabric generated and + # vetted every hook command above. + "bypass_hook_trust": True, + }, + ) + return config + + +def sdk_config( + payload: dict[str, Any], relay: CodexRelaySettings | None +) -> CodexConfig: + return CodexConfig( + cwd=str(resolve_cwd(payload)), + env=child_environment( + payload, + relay_gateway_url=relay.gateway.url if relay is not None else None, + ), + ) + + +def _personality(payload: dict[str, Any]) -> Personality | None: + value = _optional_string(_settings(payload), "personality") + if value is None: + return None + try: + return Personality(value) + except ValueError as error: + raise AdapterConfigError( + "codex_invalid_configuration", "personality is invalid" + ) from error + + +def _reasoning_effort(payload: dict[str, Any]) -> ReasoningEffort | None: + value = _optional_string(_settings(payload), "reasoning_effort") + if value is None: + return None + try: + return ReasoningEffort(value) + except ValueError as error: + raise AdapterConfigError( + "codex_invalid_configuration", "reasoning_effort is invalid" + ) from error + + +def _output_schema(payload: dict[str, Any]) -> dict[str, Any] | None: + value = _settings(payload).get("output_schema") + if value is None: + return None + return _mapping(_json_value(value, name="output_schema"), name="output_schema") + + +def validate_payload(payload: dict[str, Any]) -> str: + """Validate pure invocation inputs before starting SDK or Relay processes.""" + + settings = _settings(payload) + _validate_settings_boundary(settings) + request_prompt(payload) + fabric_runtime_id = runtime_id(payload) + resolve_cwd(payload) + selected_model(payload) + sandbox(payload) + approval_mode(payload) + timeout_seconds(payload) + for name in ( + "base_instructions", + "developer_instructions", + "service_name", + "service_tier", + ): + _optional_string(settings, name) + _personality(payload) + _reasoning_effort(payload) + _output_schema(payload) + child_environment(payload) + thread_config(payload, None) + return fabric_runtime_id + + +def _json_safe(value: Any) -> Any: + if hasattr(value, "model_dump"): + return _json_safe(value.model_dump(mode="json", by_alias=True)) + if is_dataclass(value) and not isinstance(value, type): + return _json_safe(asdict(value)) + if isinstance(value, Enum): + return _json_safe(value.value) + if isinstance(value, dict): + return {str(key): _json_safe(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(item) for item in value] + if isinstance(value, Path): + return str(value) + if value is None or isinstance(value, (str, int, float, bool)): + return value + raise AdapterConfigError( + "codex_invalid_configuration", "Codex SDK result is not JSON-safe" + ) + + +def _failure( + code: str, + message: str, + *, + retryable: bool = False, + **metadata: Any, +) -> dict[str, Any]: + error: dict[str, Any] = { + "code": code, + "message": message, + "retryable": retryable, + } + if metadata: + error["metadata"] = metadata + return { + "harness": "codex", + "adapter": "sdk", + "mode": "codex_sdk_runtime", + "response": None, + "completed": False, + "failed": True, + "error": error, + "events": [], + } + + +def adapter_failure(error: CodexAdapterError) -> dict[str, Any]: + return _failure(error.code, error.message, **error.metadata) + + +def sdk_failure(error: BaseException) -> dict[str, Any]: + if isinstance(error, TimeoutError): + return _failure("codex_timed_out", "Codex invocation timed out") + if isinstance(error, TransportClosedError): + return _failure( + "codex_connection_failed", "Codex SDK runtime connection closed" + ) + if isinstance(error, CodexError): + return _failure( + "codex_sdk_failed", + "Codex SDK request failed", + retryable=is_retryable_error(error), + sdk_error=type(error).__name__, + ) + if isinstance(error, OSError): + return _failure( + "codex_runtime_unavailable", "Codex SDK runtime could not start" + ) + return _failure( + "codex_turn_failed", + str(error) or "Codex turn failed", + ) + + +def normalize_result( + payload: dict[str, Any], *, thread_id: str, result: Any +) -> dict[str, Any]: + status = _json_safe(result.status) + completed = result.status == TurnStatus.completed and result.final_response is not None + error = None + if not completed: + message = ( + result.error.message + if result.error is not None + else "Codex invocation did not return a final response" + ) + error = { + "code": "codex_turn_incomplete", + "message": message, + "retryable": False, + "metadata": {"status": status}, + } + return { + "harness": "codex", + "adapter": "sdk", + "mode": "codex_sdk_runtime", + "cwd": str(resolve_cwd(payload)), + "model": selected_model(payload), + "thread_id": thread_id, + "turn_id": result.id, + "turn_status": status, + "response": result.final_response, + "usage": _json_safe(result.usage), + "started_at": result.started_at, + "completed_at": result.completed_at, + "duration_ms": result.duration_ms, + "completed": completed, + "failed": not completed, + "error": error, + "events": [_json_safe(item) for item in result.items], + "state_dir": str(state_dir(payload)), + } + + +async def _interrupt_turn(handle: Any) -> None: + if handle is None: + return + try: + async with asyncio.timeout(INTERRUPT_TIMEOUT_SECONDS): + await handle.interrupt() + except (TimeoutError, CodexError, RuntimeError, OSError): + # The SDK process is closed immediately afterwards, which is the final + # cancellation boundary if the runtime cannot acknowledge interrupt. + pass + + +async def invoke_codex_sdk( + payload: dict[str, Any], + *, + prior_thread_id: str | None, + relay: CodexRelaySettings | None, +) -> tuple[dict[str, Any], str | None]: + """Execute one SDK turn and always close the app-server transport.""" + + settings = _settings(payload) + config = thread_config(payload, relay) + codex = AsyncCodex(config=sdk_config(payload, relay)) + handle = None + output: dict[str, Any] + thread_id: str | None = None + try: + async with asyncio.timeout(timeout_seconds(payload)): + common = { + "approval_mode": approval_mode(payload), + "base_instructions": _optional_string(settings, "base_instructions"), + "config": config or None, + "cwd": str(resolve_cwd(payload)), + "developer_instructions": _optional_string( + settings, "developer_instructions" + ), + "model": selected_model(payload), + # Keep the SDK's built-in provider authoritative. Relay-enabled + # execution changes only its request-scoped base URL. + "model_provider": "openai", + "personality": _personality(payload), + "sandbox": sandbox(payload), + "service_tier": _optional_string(settings, "service_tier"), + } + if prior_thread_id is None: + thread = await codex.thread_start( + **common, + service_name=_optional_string(settings, "service_name"), + ) + else: + thread = await codex.thread_resume(prior_thread_id, **common) + if thread.id != prior_thread_id: + raise AdapterStateError( + "codex_thread_mismatch", + "Codex thread identity changed during resume", + ) + thread_id = thread.id + handle = await thread.turn( + request_prompt(payload), + effort=_reasoning_effort(payload), + output_schema=_output_schema(payload), + ) + result = await handle.run() + output = normalize_result(payload, thread_id=thread.id, result=result) + except TimeoutError as error: + await _interrupt_turn(handle) + output = sdk_failure(error) + except CodexAdapterError: + raise + except (CodexError, RuntimeError, OSError) as error: + output = sdk_failure(error) + finally: + try: + await codex.close() + except Exception: + output = _failure( + "codex_sdk_stop_failed", "Codex SDK runtime failed to stop" + ) + return output, thread_id + + +def _relay_output( + output: dict[str, Any], relay: CodexRelaySettings +) -> dict[str, Any]: + output["relay_runtime"] = { + "enabled": True, + "emitter": "codex-sdk/nemo-relay", + "config_path": os.environ.get("FABRIC_RELAY_CONFIG_PATH"), + "gateway_config_path": str(relay.gateway.config_path), + "gateway_url": relay.gateway.url, + "gateway_log_path": str(relay.gateway.log_path), + } + output["relay_artifacts"] = common_utils.collect_relay_artifacts( + relay.plugin_config + ) + return output + + +async def run_codex(payload: dict[str, Any]) -> dict[str, Any]: + """Run one Fabric invocation with SDK-owned Codex execution.""" + + fabric_runtime_id = validate_payload(payload) + prior_thread_id = load_thread_id(payload, fabric_runtime_id) + relay = prepare_codex_relay(payload) + gateway_process = None + cleanup_error: AdapterRelayError | None = None + try: + if relay is not None: + try: + gateway_process = relay_gateway.start_relay_gateway( + launch=relay.gateway, cwd=resolve_cwd(payload) + ) + except relay_gateway.RelayGatewayError as error: + raise AdapterRelayError( + "codex_relay_start_failed", + "NeMo Relay gateway failed to start", + metadata={"gateway_log_path": str(relay.gateway.log_path)}, + ) from error + output, thread_id = await invoke_codex_sdk( + payload, prior_thread_id=prior_thread_id, relay=relay + ) + if not output["failed"] and thread_id is not None: + save_thread_id(payload, fabric_runtime_id, thread_id) + finally: + if gateway_process is not None: + try: + relay_gateway.stop_relay_gateway(gateway_process) + except relay_gateway.RelayGatewayError: + cleanup_error = AdapterRelayError( + "codex_relay_stop_failed", + "NeMo Relay gateway failed to stop", + metadata={ + "gateway_log_path": str(relay.gateway.log_path) + if relay is not None + else "" + }, + ) + + if relay is not None: + output = _relay_output(output, relay) + if cleanup_error is not None: + cleanup: dict[str, Any] = { + "code": cleanup_error.code, + "message": cleanup_error.message, + "retryable": False, + } + if cleanup_error.metadata: + cleanup["metadata"] = cleanup_error.metadata + output["relay_runtime"]["cleanup_error"] = cleanup + if not output["failed"]: + output["completed"] = False + output["failed"] = True + output["error"] = cleanup + return output + + +def run(payload: dict[str, Any]) -> dict[str, Any]: + """Run one Fabric invocation from the synchronous adapter boundary.""" + + try: + return asyncio.run(run_codex(payload)) + except CodexAdapterError as error: + return adapter_failure(error) + except Exception: + return _failure( + "codex_adapter_internal_error", "Codex adapter failed unexpectedly" + ) + + +def main() -> None: + try: + payload = common_utils.load_payload() + except Exception: + output = _failure( + "codex_adapter_internal_error", "Codex adapter failed unexpectedly" + ) + else: + output = run(payload) + print(json.dumps(output, sort_keys=True)) + if output.get("failed"): + raise SystemExit(2) + + +if __name__ == "__main__": + main() diff --git a/adapters/codex/uv.lock b/adapters/codex/uv.lock new file mode 100644 index 000000000..7846fbaeb --- /dev/null +++ b/adapters/codex/uv.lock @@ -0,0 +1,212 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[manifest] +constraints = [{ name = "openai-codex-cli-bin", specifier = "==0.137.0a4" }] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "nemo-fabric-adapters-codex" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "nemo-fabric-adapters-common" }, + { name = "openai-codex" }, + { name = "tomli-w" }, +] + +[package.metadata] +requires-dist = [ + { name = "nemo-fabric-adapters-common", editable = "../common" }, + { name = "openai-codex", specifier = "==0.1.0b3" }, + { name = "tomli-w", specifier = "~=1.2" }, +] + +[[package]] +name = "nemo-fabric-adapters-common" +version = "0.1.0" +source = { editable = "../common" } + +[[package]] +name = "openai-codex" +version = "0.1.0b3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openai-codex-cli-bin" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1c/1e5e8b83ea72164d32b1f4e67fc703c8b83591f498a7aaf96f39d352b453/openai_codex-0.1.0b3.tar.gz", hash = "sha256:b76b7afe97953ac65648e9b8ca116b5ff273de91086549bd7ec88037cdc16cab", size = 58995, upload-time = "2026-06-03T19:17:34.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ef/f77037d9ccde80a688a17a06aea5a56813ad9c365d49b3f1c7913422af8b/openai_codex-0.1.0b3-py3-none-any.whl", hash = "sha256:8d1f9d346667aeecb435c6a45d0edb3f016187276ec452cf8094d813896276c4", size = 65639, upload-time = "2026-06-03T19:17:33.208Z" }, +] + +[[package]] +name = "openai-codex-cli-bin" +version = "0.137.0a4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/60/af73ef1676cd477fa83ed4b889bf3b57c63c47dd87025b2cc4262793cff6/openai_codex_cli_bin-0.137.0a4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b33c3917e0b58d527ee11a11a78ad390f7d8e6aa25577dd21665ab3c8bf5cf9a", size = 94300191, upload-time = "2026-06-03T18:44:36.312Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/d1a5f8c87176e00ef6a85798794f4530f5eb04e5a1a13468b5b3c3a361f9/openai_codex_cli_bin-0.137.0a4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3d0f0bc5becc88c61952fbfa9bd792ac9d74fa78b3a6bd40f545b612048b07eb", size = 83924479, upload-time = "2026-06-03T18:44:40.854Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3c/fc00bcdc0c302208317d5eb1d0bfaab3024f351cd0121400f19baa6b19aa/openai_codex_cli_bin-0.137.0a4-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:2f1656339e2736868c4cce59f6d9e5c633879123687169b03b1137d42bf2c11a", size = 83363315, upload-time = "2026-06-03T18:44:44.851Z" }, + { url = "https://files.pythonhosted.org/packages/ec/09/39362e944ebeb12fcbfb86881fbb4dd6e806f77f7541c1f1f993bb9351a0/openai_codex_cli_bin-0.137.0a4-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:6454f838d44c56c1ed07a29b391fa412785e5dd2ffd06db0b62e62478c19bb64", size = 90611239, upload-time = "2026-06-03T18:44:49.338Z" }, + { url = "https://files.pythonhosted.org/packages/fa/38/87b1247fdfe95cddce7f7fe8331d6843cf037e14292c0f5004e23247133b/openai_codex_cli_bin-0.137.0a4-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f5ae7401d00c65d56a75d9645d7bf87d809566a12d238e4b2a8b328a02f2316e", size = 83363315, upload-time = "2026-06-03T18:44:53.428Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c4/3c693ad07e587f6b3a28128c417f2e831d81a40cdbd85c0e5f0f36aaff82/openai_codex_cli_bin-0.137.0a4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3dcec1e649448be498d6e7ec0e1f71dca83efa76063d90890dafb41e987069b7", size = 90611238, upload-time = "2026-06-03T18:44:57.612Z" }, + { url = "https://files.pythonhosted.org/packages/9e/26/81e037066b9b8d312a6f9e09015e452ce17630d5ab88e02a4c1d9503e4e8/openai_codex_cli_bin-0.137.0a4-py3-none-win_amd64.whl", hash = "sha256:9e13bf68e18e36bd3a0efd51213281c83e9f6ec22bdb7a45bd2e0211822733a9", size = 94744969, upload-time = "2026-06-03T18:45:02.23Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a3/952bc2a5d62373a51fea161effe3b338b3417c2f6e65fe467ed91b205e2b/openai_codex_cli_bin-0.137.0a4-py3-none-win_arm64.whl", hash = "sha256:5ec4303ca2dcb5f838e0de3ca7f44050b6bcdd41d281a178c3a1420a985a515d", size = 86963504, upload-time = "2026-06-03T18:45:07.131Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py b/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py index 8e8faad4d..5dc86f503 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py +++ b/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py @@ -7,15 +7,12 @@ import shlex from pathlib import Path -from typing import Any -from typing import Literal +from typing import Any, Literal RelayHookAgent = Literal["claude", "codex"] -# NeMo Relay currently uses this union for both Claude Code and Codex. Keep the -# snapshot centralized until Relay exposes its hook renderer as a public API. -RELAY_HOOK_EVENTS = ( +CLAUDE_RELAY_HOOK_EVENTS = ( "SessionStart", "UserPromptSubmit", "UserPromptExpansion", @@ -31,6 +28,18 @@ "PostCompact", "SessionEnd", ) +CODEX_RELAY_HOOK_EVENTS = ( + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Stop", + "PreCompact", + "PostCompact", +) RELAY_TOOL_HOOK_EVENTS = frozenset( { "PreToolUse", @@ -52,7 +61,12 @@ def render_relay_hooks( command = f"{shlex.quote(str(executable))} hook-forward {agent}" hooks: dict[str, list[dict[str, Any]]] = {} - for event in RELAY_HOOK_EVENTS: + events = ( + CLAUDE_RELAY_HOOK_EVENTS + if agent == "claude" + else CODEX_RELAY_HOOK_EVENTS + ) + for event in events: group: dict[str, Any] = { "hooks": [ { diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index bdc191977..5d0bcfde9 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -307,7 +307,7 @@ def _mcp_connection(name: str, spec: dict[str, Any]) -> dict[str, Any]: # --- runtime / resume state ------------------------------------------------ # # Resume is keyed by the Fabric ``runtime_id`` (stable across ``invoke`` calls in -# a started runtime, fresh for each one-shot ``run``), mirroring the codex-cli +# a started runtime, fresh for each one-shot ``run``), mirroring the Codex # adapter. LangGraph owns the transcript via a persistent SQLite checkpointer; # Fabric owns the runtime-to-LangGraph-thread correlation record. diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index ff74fc2ad..b53bf4a52 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -2301,7 +2301,7 @@ schema_version: fabric.agent/v1alpha1 metadata: name: demo harness: - adapter_id: nvidia.fabric.codex.cli + adapter_id: nvidia.fabric.codex runtime: telemetry: providers: @@ -2312,7 +2312,7 @@ telemetry: .expect("config with relay and native telemetry providers"); let descriptor: AdapterDescriptor = serde_json::from_value(serde_json::json!({ "contract_version": ADAPTER_CONTRACT_VERSION, - "adapter_id": "nvidia.fabric.codex.cli", + "adapter_id": "nvidia.fabric.codex", "harness": "codex", "adapter_kind": "python", "telemetry": { diff --git a/crates/fabric-core/src/doctor.rs b/crates/fabric-core/src/doctor.rs index 01c859e4e..168d6381e 100644 --- a/crates/fabric-core/src/doctor.rs +++ b/crates/fabric-core/src/doctor.rs @@ -529,10 +529,15 @@ mod tests { #[test] fn binary_requirement_can_use_harness_command_setting() { - let mut plan = - resolve_run_plan(file_config_agent_dir(), Some("codex_cli")).expect("run plan"); + let mut plan = resolve_run_plan(file_config_agent_dir(), Some("codex")).expect("run plan"); + plan.adapter_descriptor + .as_mut() + .expect("adapter descriptor") + .descriptor + .requirements + .binaries = vec!["fabric-doctor-test".to_string()]; plan.config.harness.settings.insert( - "codex_command".to_string(), + "fabric_doctor_test_command".to_string(), Value::String( std::env::current_exe() .expect("current executable") @@ -546,7 +551,7 @@ mod tests { assert!(report.checks.iter().any(|check| { check.name == "requirement.binary" && check.status == DoctorStatus::Pass - && check.message.contains("codex_command") + && check.message.contains("fabric_doctor_test_command") })); } diff --git a/docs/getting-started/install.mdx b/docs/getting-started/install.mdx index 540d3eda4..56f8f22bc 100644 --- a/docs/getting-started/install.mdx +++ b/docs/getting-started/install.mdx @@ -61,14 +61,18 @@ uv pip install --find-links nemo-fabric[runtime] ``` ### Installing the Fabric Adapters -This will install the Fabric adapter and it's dependencies, adapters intentionally have minimal dependencies, -and are intended to be installed in the same environment as the agent harness (which may or may not be the same environment as the Fabric runtime). - -At time of writing, the following adapter packages are available: -* adapters-claude -* adapters-codex-cli -* adapters-deepagents -* adapters-hermes + +This command installs a Fabric adapter and its dependencies. Adapters +intentionally have minimal dependencies and belong in the same environment as +the agent harness. That environment can differ from the Fabric runtime +environment. + +The following adapter packages are available: + +- `adapters-claude` +- `adapters-codex` +- `adapters-deepagents` +- `adapters-hermes` ```bash uv pip install --find-links nemo-fabric[] diff --git a/docs/getting-started/overview.mdx b/docs/getting-started/overview.mdx index d79394d44..4312c1fd9 100644 --- a/docs/getting-started/overview.mdx +++ b/docs/getting-started/overview.mdx @@ -10,7 +10,7 @@ SPDX-License-Identifier: Apache-2.0 */} NeMo Fabric is the harness-management layer that turns multiple agent runtimes into one configurable, observable execution surface. Applications use the same versioned config, lifecycle, result, artifact, and telemetry contracts whether -the selected harness is Hermes, Codex CLI, or a custom adapter. +the selected harness is Hermes, Codex SDK, or a custom adapter. Fabric owns the seam between an application and its harness. It resolves configuration, selects an adapter, drives the runtime lifecycle, @@ -51,7 +51,7 @@ NeMo Fabric Rust core | | resolved adapter contract v -Hermes | Codex CLI | custom harness +Hermes | Codex SDK | custom harness | v RunResult + artifacts + events + telemetry references diff --git a/docs/index.yml b/docs/index.yml index 749c0b95a..b3e195503 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -14,6 +14,8 @@ navigation: path: ./sdk/python.mdx - section: Integrations contents: + - page: Codex + path: ./integrations/codex.mdx - page: Harbor path: ./integrations/harbor.mdx - section: Reference diff --git a/docs/integrations/codex.mdx b/docs/integrations/codex.mdx new file mode 100644 index 000000000..052b9ee86 --- /dev/null +++ b/docs/integrations/codex.mdx @@ -0,0 +1,65 @@ +--- +title: "Authenticate the Codex Adapter" +description: "Configure ChatGPT or API key authentication for the NeMo Fabric Codex SDK adapter." +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +# Authenticate the Codex Adapter + +The `nvidia.fabric.codex` adapter uses the Codex Python SDK and its pinned +app-server runtime. Fabric does not execute `codex` for agent turns or fall back +to the legacy Codex CLI adapter. + +## Choose an Authentication Mode + +Codex supports the following OpenAI authentication modes for local work: + +| Mode | Credential Source | Recommended Use | +| --- | --- | --- | +| ChatGPT login | Cached Codex login under `CODEX_HOME` | Local development with an eligible ChatGPT plan | +| API key login | API key provisioned into the Codex credential store | Usage-based or noninteractive OpenAI Platform workloads | + +With ChatGPT login, Fabric can run without `OPENAI_API_KEY`. With API key login, +usage is billed through the OpenAI Platform account instead of ChatGPT plan +credits. Refer to the +[Codex authentication documentation](https://developers.openai.com/codex/auth/) +for current plan availability and login commands. + +## Reuse a Codex Login + +Complete the Codex login outside Fabric. The adapter inherits `CODEX_HOME` +(default: `~/.codex`) and the SDK reuses the cached credential. If you select a +different credential store, use the same value for login and execution: + +```bash +export CODEX_HOME=/path/to/codex-home +codex login +``` + +For API key login, provision the credential store according to the current +Codex authentication procedure: + +```bash +export CODEX_HOME=/path/to/codex-home +printenv OPENAI_API_KEY | codex login --with-api-key +``` + +The login command is a credential-provisioning step. Fabric's runtime path uses +the SDK and does not invoke the command after the credential is cached. The +adapter also forwards `OPENAI_API_KEY` and a selected model's `api_key_env`, but +the current real-agent acceptance path validates cached Codex authentication. + +Treat `CODEX_HOME/auth.json` as a secret when Codex uses file-based credential +storage. Do not commit or copy it into Fabric configuration or artifacts. + +## Use Authentication with Relay + +NeMo Relay does not replace OpenAI authentication. A Relay-enabled invocation +starts the Relay gateway as a supervised sidecar and directs the Codex SDK's +built-in OpenAI provider through that gateway. The SDK still obtains credentials +from the selected Codex authentication mode. + +Fabric supplies Relay configuration to the SDK for only the current request. It +does not copy the Codex credential store into Relay configuration or persist +credentials in Relay artifacts. diff --git a/docs/integrations/harbor.mdx b/docs/integrations/harbor.mdx index 99e759adb..e1dcc22da 100644 --- a/docs/integrations/harbor.mdx +++ b/docs/integrations/harbor.mdx @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 */} Use `nemo_fabric.integrations.harbor:FabricAgent` when Harbor owns the evaluation workflow and Fabric owns harness execution. The Harbor agent class -stays fixed while each complete Fabric config selects Hermes, Codex CLI, or +stays fixed while each complete Fabric config selects Hermes, Codex SDK, or another adapter. ```text @@ -70,7 +70,7 @@ config per execution path: | `smoke.yaml` | Deterministic scripted adapter | Credential-free Harbor, Fabric, workspace, and verifier pipeline | | `hermes.yaml` | Hermes | A model-backed harness selected through Fabric | | `hermes-relay.yaml` | Hermes with NeMo Relay | Phoenix traces plus ATOF and ATIF records | -| `codex.yaml` | Codex CLI | An existing Codex login mounted by Harbor | +| `codex.yaml` | Codex SDK | An existing Codex login mounted by Harbor | Harbor's `--model`, MCP servers, and skill directory are applied to an independent copy of the selected config for each run. Config-owned MCP servers diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 0f635c72f..7689ceebd 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -192,7 +192,7 @@ relay_config.enable_relay( The repository's [code-review example](https://github.com/NVIDIA/NeMo-Fabric/tree/main/examples/code_review_agent) -uses this pattern for complete Hermes, Codex CLI, Deep Agents, +uses this pattern for complete Hermes, Codex SDK, Deep Agents, environment, MCP, and telemetry variants. When an in-memory caller needs the same ordered overlay behavior as file-backed @@ -407,7 +407,7 @@ Use normalized Fabric fields for portable behavior: models, runtime, environment, skills, MCP, telemetry, tools, artifacts, and request context. Use `harness.settings` for adapter-owned configuration that the selected adapter -understands. Examples include Hermes-specific launch options, Codex CLI flags, +understands. Examples include Hermes-specific launch options, Codex SDK controls, or adapter-specific config file locations. Use `metadata` for caller-owned annotations that Fabric should preserve and echo diff --git a/examples/code_review_agent/README.md b/examples/code_review_agent/README.md index db84a9008..d2b21b4ce 100644 --- a/examples/code_review_agent/README.md +++ b/examples/code_review_agent/README.md @@ -59,7 +59,7 @@ The entrypoint exposes complete harness configs defined in | Variant | Command option | Additional setup | | --- | --- | --- | | Hermes | `--variant hermes` | Installed [Hermes adapter requirements](../../adapters/hermes/README.md) and `NVIDIA_API_KEY`| -| Codex CLI | `--variant codex-cli` | Installed and authenticated [Codex CLI](../../adapters/codex-cli/README.md) | +| Codex SDK | `--variant codex` | Installed [Codex adapter](../../adapters/codex/README.md) and an existing ChatGPT or API key login | | Deep Agents | `--variant deepagents` | Installed [Deep Agents adapter requirements](../../adapters/deepagents/README.md) and `NVIDIA_API_KEY` | Add `--relay` to any variant to enable the Relay ATOF and ATIF configuration: diff --git a/examples/code_review_agent/__init__.py b/examples/code_review_agent/__init__.py index bf02296ee..955b8002c 100644 --- a/examples/code_review_agent/__init__.py +++ b/examples/code_review_agent/__init__.py @@ -6,7 +6,7 @@ from examples.code_review_agent.config import ( BASE_DIR, base_config, - codex_cli_config, + codex_config, deepagents_config, hermes_config, with_fabric_managed_github_mcp, @@ -20,7 +20,7 @@ __all__ = [ "BASE_DIR", "base_config", - "codex_cli_config", + "codex_config", "deepagents_config", "hermes_config", "with_fabric_managed_github_mcp", diff --git a/examples/code_review_agent/__main__.py b/examples/code_review_agent/__main__.py index a67537fdc..12f1a888e 100644 --- a/examples/code_review_agent/__main__.py +++ b/examples/code_review_agent/__main__.py @@ -14,7 +14,7 @@ from examples.code_review_agent.config import ( BASE_DIR, - codex_cli_config, + codex_config, deepagents_config, hermes_config, with_relay, @@ -22,7 +22,7 @@ CONFIG_BUILDERS: dict[str, Callable[[], FabricConfig]] = { "hermes": hermes_config, - "codex-cli": codex_cli_config, + "codex": codex_config, "deepagents": deepagents_config, } diff --git a/examples/code_review_agent/config.py b/examples/code_review_agent/config.py index 42c709f03..c33a22549 100644 --- a/examples/code_review_agent/config.py +++ b/examples/code_review_agent/config.py @@ -99,29 +99,28 @@ def hermes_config() -> FabricConfig: return config -def codex_cli_config() -> FabricConfig: - """Return the complete Codex CLI variant without inherited capabilities.""" +def codex_config() -> FabricConfig: + """Return the complete Codex SDK variant without inherited capabilities.""" config = base_config().model_copy(deep=True) config.harness = HarnessConfig( - adapter_id="nvidia.fabric.codex.cli", + adapter_id="nvidia.fabric.codex", resolution="preinstalled", settings={ "sandbox": "workspace-write", - "skip_git_repo_check": True, - "config_overrides": {"model_reasoning_effort": "high"}, + "reasoning_effort": "high", }, ) config.models = {"default": ModelConfig(provider="openai", model="openai/gpt-5.4")} config.runtime = RuntimeConfig( input_schema="text", output_schema="message", - artifacts="./artifacts/codex-cli", + artifacts="./artifacts/codex", ) config.environment = EnvironmentConfig( provider="local", workspace=WORKSPACE, - artifacts="./artifacts/codex-cli", + artifacts="./artifacts/codex", ) config.remove_mcp_server("github") config.remove_skill_path(SKILL_PATH) diff --git a/examples/harbor/README.md b/examples/harbor/README.md index 27bbf0525..2ae48d5fe 100644 --- a/examples/harbor/README.md +++ b/examples/harbor/README.md @@ -60,7 +60,7 @@ task image; the [multi-harness demo](demo/README.md) shows one complete setup. ## Prepare a Fabric config Create one complete config for the execution path. Harbor runs one Fabric -harness at a time; to switch from Hermes, Codex CLI, or a +harness at a time; to switch from Hermes, Codex SDK, or a Relay-enabled variant, pass a different complete config through `fabric_config_path`. For example: @@ -191,7 +191,7 @@ harbor view ## Demo and tests The [multi-harness demo](demo/README.md) provides complete configs and commands -for a credential-free smoke run, Hermes, Hermes with Relay, and Codex CLI. +for a credential-free smoke run, Hermes, Hermes with Relay, and Codex SDK. Run the lightweight integration tests with: diff --git a/examples/harbor/demo/README.md b/examples/harbor/demo/README.md index b45f4f8bf..56b648716 100644 --- a/examples/harbor/demo/README.md +++ b/examples/harbor/demo/README.md @@ -140,15 +140,13 @@ find "$RUNS_DIR/fabric-hermes-relay" \ -print -exec python -m json.tool {} \; ``` -## 4. Codex CLI +## 4. Codex SDK Harbor mounts the host Codex login as a read-only secret. The setup command -copies it into a writable container-local `CODEX_HOME`; Fabric only passes that -environment to Codex. +copies it into a writable container-local `CODEX_HOME`; the Codex SDK app-server +uses that environment without requiring a separately installed `codex` command. ```bash -codex login status - CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}" test -f "$CODEX_HOME_DIR/auth.json" CODEX_AUTH_MOUNT="[{\"type\":\"bind\",\"source\":\"$CODEX_HOME_DIR/auth.json\",\"target\":\"/run/secrets/codex-auth.json\",\"read_only\":true}]" @@ -168,9 +166,9 @@ uv run --extra runtime --extra harbor harbor run \ --force-build ``` -The image pins Codex CLI `0.142.4`. The config uses -`danger-full-access` because Harbor's task container is the outer sandbox and -nested Linux namespace creation is unavailable there. +The Fabric Codex adapter pins the Python SDK and its bundled app-server runtime. +The config uses `danger-full-access` because Harbor's task container is the +outer sandbox and nested Linux namespace creation is unavailable there. ## Inspect results diff --git a/examples/harbor/demo/task/environment/Dockerfile b/examples/harbor/demo/task/environment/Dockerfile index 506358209..eb5b8e538 100644 --- a/examples/harbor/demo/task/environment/Dockerfile +++ b/examples/harbor/demo/task/environment/Dockerfile @@ -5,7 +5,7 @@ FROM python:3.12-slim-bookworm RUN apt-get update \ && apt-get install -y --no-install-recommends \ - build-essential curl git nodejs npm pkg-config libssl-dev \ + build-essential curl git pkg-config libssl-dev \ && rm -rf /var/lib/apt/lists/* RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh \ @@ -14,8 +14,7 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup-ini ENV PATH=/root/.cargo/bin:$PATH COPY vendor/nemo-fabric /opt/nemo-fabric -RUN pip install --no-cache-dir -e "/opt/nemo-fabric[codex,harbor,hermes,relay,runtime]" \ - && npm install --global @openai/codex@0.142.4 +RUN pip install --no-cache-dir -e "/opt/nemo-fabric[codex,harbor,hermes,relay,runtime]" COPY calculator.py /app/calculator.py COPY fabric /opt/fabric-demo diff --git a/examples/harbor/demo/task/environment/fabric/configs/codex.yaml b/examples/harbor/demo/task/environment/fabric/configs/codex.yaml index 40d44b875..212e465b9 100644 --- a/examples/harbor/demo/task/environment/fabric/configs/codex.yaml +++ b/examples/harbor/demo/task/environment/fabric/configs/codex.yaml @@ -5,17 +5,14 @@ schema_version: fabric.agent/v1alpha1 metadata: name: harbor-calculator-demo - description: Codex CLI code-repair example in a Harbor task environment. + description: Codex SDK code-repair example in a Harbor task environment. harness: - adapter_id: nvidia.fabric.codex.cli + adapter_id: nvidia.fabric.codex resolution: preinstalled settings: - cwd: /app sandbox: danger-full-access - skip_git_repo_check: true - config_overrides: - model_reasoning_effort: high + reasoning_effort: high models: default: diff --git a/justfile b/justfile index f6228f2e6..3d2a08e99 100644 --- a/justfile +++ b/justfile @@ -10,7 +10,7 @@ no_uv := "false" # When set, versioning and packaging targets use this exact release version. ref_name := "" -python_projects := ". python adapters/common adapters/claude adapters/codex-cli adapters/deepagents adapters/hermes" +python_projects := ". python adapters/common adapters/claude adapters/codex adapters/deepagents adapters/hermes" bash_helpers := ''' set -euo pipefail diff --git a/pyproject.toml b/pyproject.toml index a8b5fa02d..c6f7abb25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,8 @@ adapters-common = [ "nemo-fabric-adapters-common == 0.1.0", ] -adapters-codex-cli = [ - "nemo-fabric-adapters-codex-cli == 0.1.0", +adapters-codex = [ + "nemo-fabric-adapters-codex == 0.1.0", ] adapters-claude = [ @@ -55,7 +55,7 @@ adapters-hermes = [ ] codex = [ - "nemo-fabric-adapters-codex-cli == 0.1.0", + "nemo-fabric-adapters-codex == 0.1.0", ] claude = [ @@ -89,7 +89,7 @@ runtime = [ adapters = [ "nemo-fabric-adapters-common", "nemo-fabric-adapters-claude", - "nemo-fabric-adapters-codex-cli", + "nemo-fabric-adapters-codex", "nemo-fabric-adapters-deepagents", "nemo-fabric-adapters-hermes; python_version < '3.14'", ] @@ -119,12 +119,15 @@ test = [ [tool.uv] default-groups = ["adapters", "dev", "test"] +# openai-codex beta 3 pins this prerelease app-server runtime. Keep the +# exception narrow instead of enabling prereleases for the full environment. +constraint-dependencies = ["openai-codex-cli-bin==0.137.0a4"] [tool.uv.sources] nemo-fabric-runtime = { path = "python", editable = true } nemo-fabric-adapters-common = { path = "adapters/common", editable = true } nemo-fabric-adapters-claude = { path = "adapters/claude", editable = true } -nemo-fabric-adapters-codex-cli = { path = "adapters/codex-cli", editable = true } +nemo-fabric-adapters-codex = { path = "adapters/codex", editable = true } nemo-fabric-adapters-deepagents = { path = "adapters/deepagents", editable = true } nemo-fabric-adapters-hermes = { path = "adapters/hermes", editable = true } diff --git a/tests/adapters/test_adapters_common_relay_hooks.py b/tests/adapters/test_adapters_common_relay_hooks.py index c4c1b4743..4d89f24ff 100644 --- a/tests/adapters/test_adapters_common_relay_hooks.py +++ b/tests/adapters/test_adapters_common_relay_hooks.py @@ -9,7 +9,7 @@ import nemo_fabric_adapters.common.relay_hooks as relay_hooks -EXPECTED_EVENTS = ( +CLAUDE_EXPECTED_EVENTS = ( "SessionStart", "UserPromptSubmit", "UserPromptExpansion", @@ -25,15 +25,33 @@ "PostCompact", "SessionEnd", ) +CODEX_EXPECTED_EVENTS = ( + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Stop", + "PreCompact", + "PostCompact", +) -@pytest.mark.parametrize("agent", ["claude", "codex"]) -def test_render_relay_hooks_matches_relay_agent_contract(agent): +@pytest.mark.parametrize( + ("agent", "expected_events"), + [ + ("claude", CLAUDE_EXPECTED_EVENTS), + ("codex", CODEX_EXPECTED_EVENTS), + ], +) +def test_render_relay_hooks_matches_relay_agent_contract(agent, expected_events): executable = Path("/opt/nvidia relay/bin/nemo-relay") hooks = relay_hooks.render_relay_hooks(agent, executable)["hooks"] - assert tuple(hooks) == EXPECTED_EVENTS + assert tuple(hooks) == expected_events assert hooks["SessionStart"] == [ { "hooks": [ @@ -47,12 +65,16 @@ def test_render_relay_hooks_matches_relay_agent_contract(agent): ] assert { event for event, groups in hooks.items() if groups[0].get("matcher") == "*" - } == { - "PreToolUse", - "PostToolUse", - "PostToolUseFailure", - "PermissionRequest", - } + } == ( + { + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionRequest", + } + if agent == "claude" + else {"PreToolUse", "PostToolUse", "PermissionRequest"} + ) def test_render_relay_hooks_rejects_unsupported_agent(): diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py new file mode 100644 index 000000000..bd2438d1d --- /dev/null +++ b/tests/adapters/test_codex_adapter.py @@ -0,0 +1,559 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import json +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from nemo_fabric import Fabric +from nemo_fabric_adapters.codex import adapter +from openai_codex.types import TurnStatus + + +@pytest.fixture(name="codex_payload") +def codex_payload_fixture(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + return { + "effective_config": { + "agent_name": "codex-test", + "config_root": str(tmp_path), + "config": { + "harness": { + "adapter_id": "nvidia.fabric.codex", + "settings": { + "sandbox": "workspace-write", + "config_overrides": { + "features.web_search": False, + "model_reasoning_effort": "high", + }, + }, + }, + "models": { + "default": { + "provider": "openai", + "model": "openai/gpt-5.4", + } + }, + "runtime": {}, + }, + }, + "runtime_context": { + "runtime_id": "runtime-1", + "invocation_id": "invocation-1", + "request_id": "request-1", + "environment": {"workspace": str(workspace)}, + "artifacts": {"root": str(tmp_path / "artifacts")}, + }, + "request": {"input": "Inspect the change."}, + } + + +def successful_result(response="done"): + return SimpleNamespace( + id="turn-1", + status=TurnStatus.completed, + error=None, + started_at=100, + completed_at=101, + duration_ms=1000, + final_response=response, + items=[ + { + "id": "item-1", + "type": "agentMessage", + "phase": "final_answer", + "text": response, + } + ], + usage={"total": {"inputTokens": 10, "outputTokens": 3}}, + ) + + +class FakeTurnHandle: + def __init__(self, result=None): + self.result = result or successful_result() + self.interrupted = False + + async def run(self): + if isinstance(self.result, BaseException): + raise self.result + return self.result + + async def interrupt(self): + self.interrupted = True + + +class FakeThread: + def __init__(self, thread_id, result=None): + self.id = thread_id + self.handle = FakeTurnHandle(result) + self.turn_calls = [] + + async def turn(self, prompt, **kwargs): + self.turn_calls.append((prompt, kwargs)) + return self.handle + + +class FakeAsyncCodex: + instances = [] + next_thread_id = "thread-123" + next_result = None + + def __init__(self, config): + self.config = config + self.start_calls = [] + self.resume_calls = [] + self.closed = False + self.thread = None + type(self).instances.append(self) + + async def thread_start(self, **kwargs): + self.start_calls.append(kwargs) + self.thread = FakeThread(type(self).next_thread_id, type(self).next_result) + return self.thread + + async def thread_resume(self, thread_id, **kwargs): + self.resume_calls.append((thread_id, kwargs)) + self.thread = FakeThread(thread_id, type(self).next_result) + return self.thread + + async def close(self): + self.closed = True + + +@pytest.fixture(name="fake_codex") +def fake_codex_fixture(monkeypatch): + FakeAsyncCodex.instances = [] + FakeAsyncCodex.next_thread_id = "thread-123" + FakeAsyncCodex.next_result = None + monkeypatch.setattr(adapter, "AsyncCodex", FakeAsyncCodex) + return FakeAsyncCodex + + +def test_sdk_oneshot_uses_native_thread_and_turn_contract( + codex_payload, fake_codex, monkeypatch, tmp_path +): + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex-home")) + monkeypatch.setenv("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", "parent-codex") + monkeypatch.setenv("FABRIC_UNRELATED_SECRET", "do-not-forward") + codex_payload["effective_config"]["config"]["harness"]["settings"]["env"] = { + "CODEX_EXPLICIT": "forward-me" + } + + output = adapter.run(codex_payload) + + assert output["completed"] is True + assert output["adapter"] == "sdk" + assert output["mode"] == "codex_sdk_runtime" + assert output["thread_id"] == "thread-123" + assert output["turn_id"] == "turn-1" + assert output["response"] == "done" + assert output["events"][0]["type"] == "agentMessage" + assert "command" not in output + assert "returncode" not in output + + client = fake_codex.instances[0] + assert client.closed is True + assert client.config.codex_bin is None + assert client.config.launch_args_override is None + assert client.config.cwd == str( + Path(codex_payload["runtime_context"]["environment"]["workspace"]) + ) + assert client.config.env["CODEX_HOME"] == str(tmp_path / "codex-home") + assert client.config.env["CODEX_EXPLICIT"] == "forward-me" + assert ( + client.config.env["CODEX_INTERNAL_ORIGINATOR_OVERRIDE"] + == "codex_python_sdk" + ) + assert client.config.env["FABRIC_UNRELATED_SECRET"] == "" + start = client.start_calls[0] + assert start["model"] == "gpt-5.4" + assert start["model_provider"] == "openai" + assert start["sandbox"] == adapter.Sandbox.workspace_write + assert start["config"] == { + "features": {"web_search": False}, + "model_reasoning_effort": "high", + } + assert client.thread.turn_calls == [ + ("Inspect the change.", {"effort": None, "output_schema": None}) + ] + + +def test_runtime_resumes_sdk_thread_across_invocations( + codex_payload, fake_codex +): + first = adapter.run(codex_payload) + codex_payload["runtime_context"]["invocation_id"] = "invocation-2" + codex_payload["request"]["input"] = "Continue." + second = adapter.run(codex_payload) + + assert first["thread_id"] == second["thread_id"] == "thread-123" + assert fake_codex.instances[0].start_calls + assert fake_codex.instances[1].resume_calls[0][0] == "thread-123" + assert fake_codex.instances[1].thread.turn_calls[0][0] == "Continue." + state = json.loads( + adapter.runtime_state_path(codex_payload, "runtime-1").read_text( + encoding="utf-8" + ) + ) + assert state == { + "runtime_id": "runtime-1", + "codex_thread_id": "thread-123", + } + + +def test_runtime_rejects_corrupt_thread_state(codex_payload): + state_path = adapter.runtime_state_path(codex_payload, "runtime-1") + state_path.parent.mkdir(parents=True) + state_path.write_text("{", encoding="utf-8") + + output = adapter.run(codex_payload) + + assert output["error"]["code"] == "codex_invalid_runtime_state" + + +def test_failed_sdk_turn_is_normalized_and_transport_is_closed( + codex_payload, fake_codex +): + fake_codex.next_result = RuntimeError("model request failed") + + output = adapter.run(codex_payload) + + assert output["error"] == { + "code": "codex_turn_failed", + "message": "model request failed", + "retryable": False, + } + assert fake_codex.instances[0].closed is True + assert not adapter.runtime_state_path(codex_payload, "runtime-1").exists() + + +def test_incomplete_sdk_turn_is_failed_without_persisting_thread( + codex_payload, fake_codex +): + result = successful_result(response=None) + fake_codex.next_result = result + + output = adapter.run(codex_payload) + + assert output["error"]["code"] == "codex_turn_incomplete" + assert output["turn_status"] == "completed" + assert not adapter.runtime_state_path(codex_payload, "runtime-1").exists() + + +def test_selected_model_rejects_unsupported_provider(codex_payload, fake_codex): + model = codex_payload["effective_config"]["config"]["models"]["default"] + model["provider"] = "nvidia" + + output = adapter.run(codex_payload) + + assert output["error"]["code"] == "codex_invalid_configuration" + assert "provider must be openai" in output["error"]["message"] + assert fake_codex.instances == [] + + +def test_resume_rejects_changed_sdk_thread_identity( + codex_payload, fake_codex, monkeypatch +): + adapter.save_thread_id(codex_payload, "runtime-1", "thread-persisted") + + async def thread_resume(self, thread_id, **kwargs): + self.resume_calls.append((thread_id, kwargs)) + self.thread = FakeThread("thread-replaced") + return self.thread + + monkeypatch.setattr(fake_codex, "thread_resume", thread_resume) + + output = adapter.run(codex_payload) + + assert output["error"]["code"] == "codex_thread_mismatch" + assert fake_codex.instances[0].closed is True + + +def test_relay_uses_gateway_and_request_scoped_sdk_config( + codex_payload, fake_codex, monkeypatch, tmp_path +): + codex_payload["telemetry_plan"] = { + "providers": ["relay"], + "relay_enabled": True, + } + relay_config_path = tmp_path / "relay-config" / "config.toml" + executable = tmp_path / "bin" / "nemo-relay" + gateway = adapter.relay_gateway.RelayGatewayLaunch( + executable=executable, + config_path=relay_config_path, + bind="127.0.0.1:43210", + url="http://127.0.0.1:43210", + log_path=relay_config_path.parent / "gateway.log", + ) + relay = adapter.CodexRelaySettings( + gateway=gateway, + plugin_config={"version": 1, "components": []}, + ) + process = MagicMock() + start_gateway = MagicMock(return_value=process) + stop_gateway = MagicMock() + monkeypatch.setattr(adapter, "prepare_codex_relay", MagicMock(return_value=relay)) + monkeypatch.setattr( + adapter.relay_gateway, "start_relay_gateway", start_gateway + ) + monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", stop_gateway) + monkeypatch.setenv("FABRIC_RELAY_CONFIG_PATH", str(tmp_path / "relay.json")) + + output = adapter.run(codex_payload) + + client = fake_codex.instances[0] + config = client.start_calls[0]["config"] + assert client.start_calls[0]["model_provider"] == "openai" + assert client.config.env["NEMO_RELAY_GATEWAY_URL"] == gateway.url + assert config["bypass_hook_trust"] is True + assert config["features"]["hooks"] is True + assert config["features"]["web_search"] is False + assert config["openai_base_url"] == gateway.url + assert "model_provider" not in config + assert "model_providers" not in config + assert config["hooks"]["SessionStart"][0]["hooks"][0] == { + "type": "command", + "command": f"{executable} hook-forward codex", + "timeout": 30, + } + assert output["relay_runtime"] == { + "enabled": True, + "emitter": "codex-sdk/nemo-relay", + "config_path": str(tmp_path / "relay.json"), + "gateway_config_path": str(relay_config_path), + "gateway_url": gateway.url, + "gateway_log_path": str(gateway.log_path), + } + assert output["relay_artifacts"] == [] + start_gateway.assert_called_once_with( + launch=gateway, + cwd=Path(codex_payload["runtime_context"]["environment"]["workspace"]), + ) + stop_gateway.assert_called_once_with(process) + + +def test_prepare_relay_reuses_one_resolved_executable( + codex_payload, monkeypatch, tmp_path +): + codex_payload["telemetry_plan"] = { + "providers": ["relay"], + "relay_enabled": True, + } + executable = tmp_path / "nemo-relay" + config_path = tmp_path / "relay-config" / "config.toml" + plugin_path = config_path.parent / "plugins.toml" + resolve = MagicMock(return_value=executable) + version = MagicMock(return_value=2) + write = MagicMock(return_value=(config_path, plugin_path)) + monkeypatch.setattr(adapter.relay_gateway, "resolve_relay_command", resolve) + monkeypatch.setattr( + adapter.relay_gateway, "relay_cli_observability_version", version + ) + monkeypatch.setattr(adapter.relay_gateway, "find_available_tcp_port", lambda: 43210) + monkeypatch.setattr( + adapter.common_utils, + "load_relay_plugin_config", + MagicMock(return_value={"version": 1, "components": []}), + ) + monkeypatch.setattr(adapter.common_utils, "write_relay_configs", write) + + relay = adapter.prepare_codex_relay(codex_payload) + + assert relay is not None + assert relay.gateway.executable == executable + assert relay.gateway.url == "http://127.0.0.1:43210" + version.assert_called_once_with(executable) + write.assert_called_once_with( + relay_config={}, + plugin_config={"version": 1, "components": []}, + observability_version=2, + ) + + +def test_relay_cleanup_failure_changes_success_to_failure( + codex_payload, fake_codex, monkeypatch, tmp_path +): + gateway = adapter.relay_gateway.RelayGatewayLaunch( + executable=tmp_path / "nemo-relay", + config_path=tmp_path / "relay" / "config.toml", + bind="127.0.0.1:43210", + url="http://127.0.0.1:43210", + log_path=tmp_path / "relay" / "gateway.log", + ) + relay = adapter.CodexRelaySettings( + gateway=gateway, + plugin_config={"version": 1, "components": []}, + ) + monkeypatch.setattr(adapter, "prepare_codex_relay", lambda _: relay) + monkeypatch.setattr( + adapter.relay_gateway, "start_relay_gateway", lambda **_: MagicMock() + ) + monkeypatch.setattr( + adapter.relay_gateway, + "stop_relay_gateway", + MagicMock(side_effect=adapter.relay_gateway.RelayGatewayError("stuck")), + ) + + output = adapter.run(codex_payload) + + assert output["failed"] is True + assert output["completed"] is False + assert output["error"]["code"] == "codex_relay_stop_failed" + assert output["relay_runtime"]["cleanup_error"] == output["error"] + + +def test_native_sdk_controls_and_telemetry_are_request_scoped( + codex_payload, fake_codex +): + settings = codex_payload["effective_config"]["config"]["harness"]["settings"] + settings.update( + { + "personality": "pragmatic", + "reasoning_effort": "xhigh", + "service_name": "fabric-codex-test", + "output_schema": { + "type": "object", + "properties": {"summary": {"type": "string"}}, + "required": ["summary"], + }, + } + ) + codex_payload["telemetry_plan"] = { + "providers": ["native"], + "relay_enabled": False, + "native_config": { + "components": [ + { + "kind": "observability", + "enabled": True, + "config": { + "opentelemetry": { + "enabled": True, + "endpoint": "http://localhost:4318/v1/traces", + "transport": "http_binary", + "resource_attributes": { + "deployment.environment": "test" + }, + } + }, + } + ] + }, + } + + output = adapter.run(codex_payload) + + assert output["failed"] is False + client = fake_codex.instances[0] + start = client.start_calls[0] + assert start["personality"] == adapter.Personality.pragmatic + assert start["service_name"] == "fabric-codex-test" + assert start["config"]["otel"] == { + "environment": "test", + "trace_exporter": { + "otlp-http": { + "endpoint": "http://localhost:4318/v1/traces", + "protocol": "binary", + } + }, + } + _, turn = client.thread.turn_calls[0] + assert turn["effort"] == adapter.ReasoningEffort.xhigh + assert turn["output_schema"]["required"] == ["summary"] + + +def test_timeout_interrupts_native_turn_and_closes_sdk( + codex_payload, fake_codex, monkeypatch +): + class BlockingHandle(FakeTurnHandle): + async def run(self): + await __import__("asyncio").sleep(60) + + class BlockingThread(FakeThread): + def __init__(self, thread_id, result=None): + super().__init__(thread_id, result) + self.handle = BlockingHandle() + + async def thread_start(self, **kwargs): + self.start_calls.append(kwargs) + self.thread = BlockingThread("thread-timeout") + return self.thread + + monkeypatch.setattr(fake_codex, "thread_start", thread_start) + codex_payload["effective_config"]["config"]["harness"]["settings"][ + "timeout_seconds" + ] = 0.01 + + output = adapter.run(codex_payload) + + client = fake_codex.instances[0] + assert output["error"]["code"] == "codex_timed_out" + assert client.thread.handle.interrupted is True + assert client.closed is True + + +@pytest.mark.parametrize( + "setting", ["codex_command", "codex_args", "codex_profile", "skip_git_repo_check"] +) +def test_cli_only_settings_are_rejected(codex_payload, setting): + codex_payload["effective_config"]["config"]["harness"]["settings"][setting] = ( + "legacy" + ) + + output = adapter.run(codex_payload) + + assert output["error"]["code"] == "codex_invalid_configuration" + assert setting in output["error"]["message"] + + +def test_adapter_rejects_structured_input(codex_payload): + codex_payload["request"]["input"] = { + "messages": [{"role": "user", "content": "Inspect the change."}] + } + + output = adapter.run(codex_payload) + + assert output["error"]["code"] == "codex_invalid_request" + + +def test_descriptor_has_no_codex_binary_requirement(): + descriptor = json.loads( + ( + Path(__file__).parents[2] / "adapters" / "codex" / "fabric-adapter.json" + ).read_text(encoding="utf-8") + ) + + assert descriptor["adapter_id"] == "nvidia.fabric.codex" + assert descriptor["runner"] == { + "module": "nemo_fabric_adapters.codex.adapter", + "callable": "run", + } + assert "requirements" not in descriptor + + +def test_codex_config_resolves_sdk_adapter(): + from examples.code_review_agent import BASE_DIR, codex_config + + plan = Fabric().plan(codex_config(), base_dir=BASE_DIR) + + assert plan.adapter.adapter_id == "nvidia.fabric.codex" + assert plan.adapter.harness == "codex" + assert plan.effective_config.config.runtime.input_schema == "text" + assert plan.effective_config.config.harness.settings["reasoning_effort"] == "high" + unsupported = plan["capability_plan"]["unsupported"] + assert not unsupported.get("skill_paths") + assert not unsupported.get("mcp_servers") + + +def test_environment_does_not_mutate_parent(codex_payload, monkeypatch): + monkeypatch.setenv("FABRIC_UNRELATED_SECRET", "parent-value") + + child = adapter.child_environment(codex_payload) + + assert child["FABRIC_UNRELATED_SECRET"] == "" + assert os.environ["FABRIC_UNRELATED_SECRET"] == "parent-value" diff --git a/tests/adapters/test_codex_cli.py b/tests/adapters/test_codex_cli.py deleted file mode 100644 index d46f2d734..000000000 --- a/tests/adapters/test_codex_cli.py +++ /dev/null @@ -1,744 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -import json -import os -import subprocess -import tomllib -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from nemo_fabric import Fabric -from nemo_fabric import FabricConfig -from nemo_fabric_adapters.codex_cli import adapter - - -@pytest.fixture(name="codex_payload") -def codex_payload_fixture(tmp_path): - workspace = tmp_path / "workspace" - workspace.mkdir() - codex_home = tmp_path / "codex-home" - codex_home.mkdir() - (codex_home / "team.toml").write_text("", encoding="utf-8") - os.environ["CODEX_HOME"] = str(codex_home) - return { - "effective_config": { - "agent_name": "codex-test", - "config_root": str(tmp_path), - "config": { - "harness": { - "adapter_id": "nvidia.fabric.codex.cli", - "settings": { - "sandbox": "read-only", - "codex_profile": "team", - "config_overrides": { - "features.web_search": False, - "model_reasoning_effort": "high", - }, - }, - }, - "models": { - "default": { - "provider": "openai", - "model": "openai/gpt-5.4", - } - }, - "runtime": {}, - }, - }, - "runtime_context": { - "runtime_id": "runtime-1", - "invocation_id": "invocation-1", - "request_id": "request-1", - "environment": {"workspace": str(workspace)}, - "artifacts": {"root": str(tmp_path / "artifacts")}, - }, - "request": {"input": "Inspect the change."}, - } - - -def codex_jsonl(thread_id, response, *, usage=None): - events = [ - {"type": "thread.started", "thread_id": thread_id}, - {"type": "turn.started"}, - { - "type": "item.completed", - "item": {"id": "item-1", "type": "agent_message", "text": response}, - }, - { - "type": "turn.completed", - "usage": usage - or { - "input_tokens": 10, - "cached_input_tokens": 2, - "output_tokens": 3, - }, - }, - ] - return "\n".join(json.dumps(event) for event in events) + "\n" - - -def write_mock_codex(path): - path.write_text( - """#!/usr/bin/env python3 -import json -import sys - -args = sys.argv[1:] -thread_id = args[args.index("resume") + 1] if "resume" in args else "thread-fake" -prompt = sys.stdin.read().strip() -events = [ - {"type": "thread.started", "thread_id": thread_id}, - {"type": "turn.started"}, - { - "type": "item.completed", - "item": { - "id": "item-1", - "type": "agent_message", - "text": f"{thread_id}:{prompt}", - }, - }, - { - "type": "turn.completed", - "usage": {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1}, - }, -] -for event in events: - print(json.dumps(event)) -""", - encoding="utf-8", - ) - path.chmod(0o755) - - -def fabric_config(tmp_path, mock_codex): - return FabricConfig.from_mapping( - { - "schema_version": "fabric.agent/v1alpha1", - "metadata": {"name": "codex-runtime-test"}, - "harness": { - "adapter_id": "nvidia.fabric.codex.cli", - "resolution": "preinstalled", - "settings": { - "codex_command": str(mock_codex), - "sandbox": "read-only", - "skip_git_repo_check": True, - }, - }, - "runtime": { - "artifacts": str(tmp_path / "artifacts"), - }, - "environment": { - "provider": "local", - "workspace": str(tmp_path), - "artifacts": str(tmp_path / "artifacts"), - }, - } - ) - - -def test_oneshot_command_uses_fabric_overrides_and_codex_owned_auth( - codex_payload, - tmp_path, -): - codex_settings = adapter.write_config_files(codex_payload) - - command = adapter.build_command( - codex_payload, - codex_settings=codex_settings, - ) - - exec_index = command.index("exec") - assert command[0] == "codex" - assert command[1:exec_index] == [] - assert command[exec_index : exec_index + 2] == ["exec", "--json"] - assert "--ephemeral" not in command - assert ["--sandbox", "read-only"] == command[exec_index + 2 : exec_index + 4] - assert ["--profile", "fabric-runtime-1"] == command[exec_index + 4 : exec_index + 6] - assert "--dangerously-bypass-hook-trust" not in command - assert ["--model", "gpt-5.4"] == command[-3:-1] - assert command[-1] == "-" - assert tomllib.loads( - codex_settings.codex_profile_path.read_text(encoding="utf-8") - ) == { - "features": {"web_search": False}, - "model_reasoning_effort": "high", - } - - -def test_configured_codex_profile_is_base_for_generated_profile(codex_payload): - codex_home = Path(os.environ["CODEX_HOME"]) - source_profile = codex_home / "team.toml" - source_profile.write_text( - """approval_policy = "never" -model_reasoning_effort = "medium" - -[features] -web_search = true -shell_snapshot = true -""", - encoding="utf-8", - ) - - codex_settings = adapter.write_config_files(codex_payload) - - assert codex_settings.codex_profile_name == "fabric-runtime-1" - assert tomllib.loads( - codex_settings.codex_profile_path.read_text(encoding="utf-8") - ) == { - "approval_policy": "never", - "model_reasoning_effort": "high", - "features": { - "web_search": False, - "shell_snapshot": True, - }, - } - assert tomllib.loads(source_profile.read_text(encoding="utf-8")) == { - "approval_policy": "never", - "model_reasoning_effort": "medium", - "features": { - "web_search": True, - "shell_snapshot": True, - }, - } - - -def test_relative_codex_command_resolves_from_config_root(codex_payload): - settings = codex_payload["effective_config"]["config"]["harness"]["settings"] - settings["codex_command"] = "./tools/codex" - settings["config_overrides"] = {} - - command = adapter.build_command( - codex_payload, - codex_settings=adapter.write_config_files(codex_payload), - ) - - config_root = Path(codex_payload["effective_config"]["config_root"]) - assert command[0] == str(config_root / "tools" / "codex") - - -def test_codex_home_uses_environment(tmp_path): - os.environ["CODEX_HOME"] = str(tmp_path / "custom-codex-home") - - name, path = adapter.get_codex_profile_path( - {"runtime_context": {"runtime_id": "runtime-1"}} - ) - - assert name == "fabric-runtime-1" - assert path == tmp_path / "custom-codex-home" / "fabric-runtime-1.config.toml" - - -def test_codex_home_defaults_to_user_codex_directory(): - os.environ.pop("CODEX_HOME", None) - - name, path = adapter.get_codex_profile_path( - {"runtime_context": {"runtime_id": "runtime-1"}} - ) - - assert name == "fabric-runtime-1" - assert path == Path.home() / ".codex" / "fabric-runtime-1.config.toml" - - -def test_relay_routes_codex_through_standalone_gateway( - codex_payload, - monkeypatch, - tmp_path, -): - codex_payload["telemetry_plan"] = { - "providers": ["relay"], - "relay_enabled": True, - } - mock_find_port = MagicMock(return_value=43210) - monkeypatch.setattr( - adapter.relay_gateway, "find_available_tcp_port", mock_find_port - ) - relay_executable = tmp_path / "bin" / "nemo-relay" - relay_executable.parent.mkdir() - relay_executable.touch() - monkeypatch.setattr( - adapter.relay_gateway, - "resolve_relay_command", - MagicMock(return_value=relay_executable), - ) - relay_plugin_config = {"version": 1, "components": []} - relay_config_path = tmp_path / "relay-config" / "config.toml" - mock_load_config = MagicMock(return_value=relay_plugin_config) - mock_write_config = MagicMock( - return_value=(relay_config_path, tmp_path / "plugins.toml") - ) - monkeypatch.setattr( - adapter.common_utils, - "load_relay_plugin_config", - mock_load_config, - ) - monkeypatch.setattr( - adapter.common_utils, - "write_relay_configs", - mock_write_config, - ) - monkeypatch.setattr( - adapter.relay_gateway, - "relay_cli_observability_version", - MagicMock(return_value=2), - ) - codex_settings = adapter.write_config_files(codex_payload) - - command = adapter.build_command( - codex_payload, - codex_settings=codex_settings, - ) - - assert command[0] == "codex" - assert "nemo-relay" not in command - assert "--dangerously-bypass-hook-trust" in command - assert not any(value.startswith("hooks.") for value in command) - assert "--config" not in command - assert command[command.index("--profile") + 1] == "fabric-runtime-1" - - config = tomllib.loads( - codex_settings.codex_profile_path.read_text(encoding="utf-8") - ) - assert config["model_provider"] == "nemo-relay-openai" - assert config["model_providers"]["nemo-relay-openai"] == { - "name": "NeMo Relay OpenAI", - "base_url": "http://127.0.0.1:43210", - "wire_api": "responses", - "requires_openai_auth": True, - "supports_websockets": False, - } - assert config["features"]["hooks"] is True - assert config["features"]["web_search"] is False - assert config["model_reasoning_effort"] == "high" - assert config["hooks"]["SessionStart"][0]["hooks"][0] == { - "type": "command", - "command": f"{relay_executable} hook-forward codex", - "timeout": 30, - } - assert "UserPromptExpansion" in config["hooks"] - mock_load_config.assert_called_once_with(codex_payload) - mock_write_config.assert_called_once_with( - relay_config={"agents": {"codex": {"command": "codex"}}}, - plugin_config=relay_plugin_config, - observability_version=2, - ) - - -@pytest.mark.parametrize( - ("transport", "expected_exporter", "expected_protocol"), - [ - ("http_binary", "otlp-http", "binary"), - ("grpc", "otlp-grpc", "grpc"), - ], -) -def test_native_otel_profile_writes_codex_telemetry_config( - codex_payload, - tmp_path, - transport, - expected_exporter, - expected_protocol, -): - from examples.code_review_agent import codex_cli_config - from examples.code_review_agent import with_native_otel - - config = codex_payload["effective_config"]["config"] - typed = with_native_otel(codex_cli_config()) - assert typed.telemetry is not None - native_config = typed.telemetry.to_mapping()["providers"]["native"]["config"] - native_config["components"][0]["config"]["opentelemetry"]["transport"] = transport - codex_payload["telemetry_plan"] = { - "providers": ["native"], - "relay_enabled": False, - "native_config": native_config, - } - config["harness"]["settings"]["config_overrides"] = {} - codex_settings = adapter.write_config_files(codex_payload) - - command = adapter.build_command( - codex_payload, - codex_settings=codex_settings, - ) - - assert command[command.index("--profile") + 1] == "fabric-runtime-1" - assert "--dangerously-bypass-hook-trust" not in command - assert tomllib.loads( - codex_settings.codex_profile_path.read_text(encoding="utf-8") - ) == { - "otel": { - "environment": "dev", - "trace_exporter": { - expected_exporter: { - "endpoint": "http://localhost:4318/v1/traces", - "protocol": expected_protocol, - } - }, - } - } - - -def test_run_codex_configures_relay(codex_payload, monkeypatch, tmp_path): - relay_plugin_config = {"version": 1, "components": []} - relay_config_path = tmp_path / "relay-config" / "config.toml" - mock_gateway = MagicMock() - gateway_host = "127.0.0.1:43210" - gateway_url = "http://127.0.0.1:43210" - mock_start_gateway = MagicMock(return_value=mock_gateway) - mock_stop_gateway = MagicMock() - codex_home = tmp_path / "codex-home" - profile_name = "fabric-runtime-1" - profile_path = codex_home / f"{profile_name}.config.toml" - gateway = adapter.relay_gateway.RelayGatewayLaunch( - executable=tmp_path / "nemo-relay", - config_path=relay_config_path, - bind=gateway_host, - url=gateway_url, - log_path=relay_config_path.parent / "gateway.log", - ) - codex_settings = adapter.CodexSettings( - telemetry_provider="relay", - codex_profile_name=profile_name, - codex_profile_path=profile_path, - relay=adapter.CodexRelaySettings( - gateway=gateway, - plugin_config=relay_plugin_config, - ), - ) - mock_write_config_files = MagicMock(return_value=codex_settings) - mock_run = MagicMock( - return_value=subprocess.CompletedProcess( - args=[], - returncode=0, - stdout=codex_jsonl("thread-123", "done"), - stderr="", - ) - ) - os.environ["FABRIC_RELAY_ENABLED"] = "true" - os.environ.pop("CODEX_HOME", None) - os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(tmp_path / "relay-config.json") - monkeypatch.setattr( - adapter.relay_gateway, "start_relay_gateway", mock_start_gateway - ) - monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", mock_stop_gateway) - monkeypatch.setattr( - adapter, - "write_config_files", - mock_write_config_files, - ) - monkeypatch.setattr(adapter.subprocess, "run", mock_run) - - result = adapter.run_codex(codex_payload) - - command = mock_run.call_args.args[0] - assert result["relay_runtime"] == { - "enabled": True, - "config_path": os.environ["FABRIC_RELAY_CONFIG_PATH"], - "emitter": "nemo-relay", - "gateway_config_path": str(relay_config_path), - "gateway_log_path": str(gateway.log_path), - } - assert result["relay_artifacts"] == [] - assert command[0] == "codex" - assert "nemo-relay" not in command - assert command[command.index("--profile") + 1] == profile_name - assert mock_run.call_args.kwargs["env"]["NEMO_RELAY_GATEWAY_URL"] == gateway_url - assert "CODEX_HOME" not in mock_run.call_args.kwargs["env"] - assert not profile_path.exists() - mock_write_config_files.assert_called_once_with(codex_payload) - mock_start_gateway.assert_called_once_with( - launch=gateway, - cwd=Path(codex_payload["runtime_context"]["environment"]["workspace"]), - ) - mock_stop_gateway.assert_called_once_with(mock_gateway) - - -def test_reported_command_redacts_secret_config_overrides(): - - command = ["codex", "exec", "--config", 'provider.api_key="secret"', "-"] - - assert adapter.redact_command(command)[-2] == "" - assert command[-2] == 'provider.api_key="secret"' - - -def test_config_override_values_use_tomli_writer(): - - assert adapter.toml_value("café") == '"café"' - encoded = adapter.toml_value([1, "two"]) - assert tomllib.loads(f"value = {encoded}")["value"] == [1, "two"] - with pytest.raises(ValueError, match="scalar or array"): - adapter.toml_value({"nested": True}) - - -@pytest.mark.parametrize("value", [[float("nan")], [1, [float("inf")]]]) -def test_config_override_values_reject_nested_non_finite_numbers(value): - - with pytest.raises(ValueError, match="finite numbers"): - adapter.toml_value(value) - - -def test_runtime_reuses_codex_thread_across_invocations( - codex_payload, monkeypatch, tmp_path -): - mock_run = MagicMock( - side_effect=[ - subprocess.CompletedProcess( - args=[], - returncode=0, - stdout=codex_jsonl("thread-123", "first response"), - stderr="", - ), - subprocess.CompletedProcess( - args=[], - returncode=0, - stdout=codex_jsonl("thread-123", "second response"), - stderr="", - ), - ] - ) - monkeypatch.setattr(adapter.subprocess, "run", mock_run) - os.environ.pop("OPENAI_API_KEY", None) - os.environ["CODEX_HOME"] = str(tmp_path / "codex-home") - os.environ["FABRIC_UNRELATED_SECRET"] = "do-not-forward" - codex_payload["effective_config"]["config"]["harness"]["settings"]["env"] = { - "CODEX_EXPLICIT": "forward-me" - } - - first = adapter.run_codex(codex_payload) - codex_payload["runtime_context"]["invocation_id"] = "invocation-2" - codex_payload["request"]["input"] = "Continue." - second = adapter.run_codex(codex_payload) - - first_command = mock_run.call_args_list[0].args[0] - second_command = mock_run.call_args_list[1].args[0] - assert "--ephemeral" not in first_command - assert "resume" not in first_command - assert second_command[-3:] == ["resume", "thread-123", "-"] - assert first["response"] == "first response" - assert second["response"] == "second response" - assert second["thread_id"] == "thread-123" - assert second["usage"]["cached_input_tokens"] == 2 - child_env = mock_run.call_args_list[0].kwargs["env"] - assert child_env["CODEX_HOME"] == str(tmp_path / "codex-home") - assert child_env["CODEX_EXPLICIT"] == "forward-me" - assert "OPENAI_API_KEY" not in child_env - assert mock_run.call_args_list[0].kwargs["timeout"] == 1800 - - state_path = adapter.runtime_state_path(codex_payload, "runtime-1") - assert json.loads(state_path.read_text(encoding="utf-8")) == { - "runtime_id": "runtime-1", - "thread_id": "thread-123", - } - - -def test_runtime_rejects_corrupt_codex_thread_state(codex_payload): - state_path = adapter.runtime_state_path(codex_payload, "runtime-1") - state_path.parent.mkdir(parents=True) - state_path.write_text("{", encoding="utf-8") - - with pytest.raises(RuntimeError, match="invalid Codex runtime state"): - adapter.load_thread_id(codex_payload, "runtime-1") - - -def test_runtime_persists_codex_thread_state(codex_payload, monkeypatch): - mock_run = MagicMock( - return_value=subprocess.CompletedProcess( - args=[], - returncode=0, - stdout=codex_jsonl("thread-ephemeral", "done"), - stderr="", - ) - ) - monkeypatch.setattr(adapter.subprocess, "run", mock_run) - - output = adapter.run_codex(codex_payload) - - assert output["thread_id"] == "thread-ephemeral" - assert output["response"] == "done" - assert "events" not in output - assert "stdout" not in output - assert "stderr" not in output - assert (Path(output["state_dir"]) / "runtimes").exists() - - -def test_adapter_rejects_structured_input_until_chat_is_supported(codex_payload): - codex_payload["request"]["input"] = { - "messages": [{"role": "user", "content": "Inspect the change."}] - } - - with pytest.raises(ValueError, match="requires text input"): - adapter.request_to_prompt(codex_payload) - - -@pytest.mark.parametrize("env", [[], "CODEX_FLAG=1"]) -def test_adapter_rejects_non_mapping_env(codex_payload, env): - settings = codex_payload["effective_config"]["config"]["harness"]["settings"] - settings["env"] = env - - with pytest.raises(ValueError, match="env must be a mapping"): - adapter.build_env(codex_payload) - - -@pytest.mark.parametrize( - ("error", "message", "returncode"), - [ - (FileNotFoundError("codex not found"), "codex not found", 127), - ( - subprocess.TimeoutExpired(["codex"], 1800), - "timed out after 1800 seconds", - 124, - ), - ], -) -def test_process_launch_failures_return_structured_results( - codex_payload, monkeypatch, error, message, returncode -): - monkeypatch.setattr(adapter.subprocess, "run", MagicMock(side_effect=error)) - - output = adapter.run_codex(codex_payload) - - assert output["failed"] is True - assert output["returncode"] == returncode - assert message in output["error"] - assert "stdout" not in output - assert "stderr" not in output - - -def test_thread_mismatch_preserves_process_error(codex_payload, monkeypatch): - adapter.save_thread_id(codex_payload, "runtime-1", "thread-persisted") - monkeypatch.setattr( - adapter.subprocess, - "run", - MagicMock( - return_value=subprocess.CompletedProcess( - args=[], - returncode=1, - stdout=codex_jsonl("thread-unexpected", "failed response"), - stderr="Codex process failed", - ) - ), - ) - - output = adapter.run_codex(codex_payload) - - assert output["failed"] is True - assert output["error"] == "Codex process failed" - - -@pytest.mark.parametrize("timeout", [0, -1, float("inf"), "30"]) -def test_adapter_rejects_invalid_timeout(codex_payload, timeout): - settings = codex_payload["effective_config"]["config"]["harness"]["settings"] - settings["timeout_seconds"] = timeout - - with pytest.raises(ValueError, match="timeout_seconds"): - adapter.run_codex(codex_payload) - - -def test_runtime_fails_if_codex_does_not_return_thread_identity( - codex_payload, monkeypatch -): - mock_run = MagicMock( - return_value=subprocess.CompletedProcess( - args=[], - returncode=0, - stdout=json.dumps( - { - "type": "item.completed", - "item": {"type": "agent_message", "text": "untracked"}, - } - ), - stderr="", - ) - ) - monkeypatch.setattr(adapter.subprocess, "run", mock_run) - - output = adapter.run_codex(codex_payload) - - assert output["failed"] is True - assert "thread identity" in output["error"] - - -def test_successful_process_without_final_response_is_failed( - codex_payload, monkeypatch -): - mock_run = MagicMock( - return_value=subprocess.CompletedProcess( - args=[], - returncode=0, - stdout="\n".join( - [ - json.dumps({"type": "thread.started", "thread_id": "thread-123"}), - json.dumps({"type": "turn.completed", "usage": {}}), - ] - ), - stderr="", - ) - ) - monkeypatch.setattr(adapter.subprocess, "run", mock_run) - - output = adapter.run_codex(codex_payload) - - assert output["failed"] is True - assert "final agent message" in output["error"] - - -async def test_fabric_runtime_invokes_codex_then_resumes(tmp_path): - mock_codex = tmp_path / "codex" - write_mock_codex(mock_codex) - config = fabric_config(tmp_path, mock_codex) - - async with await Fabric().start_runtime( - config, - base_dir=tmp_path, - ) as runtime: - first = await runtime.invoke(input="first") - second = await runtime.invoke(input="second") - - assert first.runtime_id == second.runtime_id - assert first.output["response"] == "thread-fake:first" - assert second.output["response"] == "thread-fake:second" - assert first.output["thread_id"] == second.output["thread_id"] == "thread-fake" - assert "resume" not in first.output["command"] - assert second.output["command"][-3:] == ["resume", "thread-fake", "-"] - - -async def test_fabric_oneshot_uses_cached_codex_auth(tmp_path): - mock_codex = tmp_path / "codex" - write_mock_codex(mock_codex) - config = fabric_config(tmp_path, mock_codex) - os.environ.pop("OPENAI_API_KEY", None) - - client = Fabric() - report = await client.doctor(config, base_dir=tmp_path) - result = await client.run( - config, - base_dir=tmp_path, - input="inspect", - ) - - assert report.status == "pass" - assert any( - check.name == "requirement.binary" and "codex_command" in check.message - for check in report.checks - ) - assert not any(check.name == "requirement.env" for check in report.checks) - assert result.output["response"] == "thread-fake:inspect" - assert "--ephemeral" not in result.output["command"] - - -def test_codex_profile_resolves_runtime_adapter(): - from examples.code_review_agent import BASE_DIR - from examples.code_review_agent import codex_cli_config - - plan = Fabric().plan( - codex_cli_config(), - base_dir=BASE_DIR, - ) - - assert plan.adapter.adapter_id == "nvidia.fabric.codex.cli" - assert plan.adapter.harness == "codex" - assert "mode" not in plan.effective_config.config.runtime - assert plan.effective_config.config.runtime.input_schema == "text" - settings = plan.effective_config.config.harness.settings - assert settings["config_overrides"]["model_reasoning_effort"] == "high" - unsupported = plan["capability_plan"]["unsupported"] - assert not unsupported.get("skill_paths") - assert not unsupported.get("mcp_servers") diff --git a/tests/e2e/test_codex.py b/tests/e2e/test_codex.py new file mode 100644 index 000000000..64cb9dbc1 --- /dev/null +++ b/tests/e2e/test_codex.py @@ -0,0 +1,119 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Opt-in real Codex SDK integration gates for Fabric runtime behavior. + +RUN_FABRIC_CODEX_INTEGRATION=1 pytest tests/e2e/test_codex.py +""" + +from __future__ import annotations + +import importlib.util +import os +import shutil +import uuid + +import pytest + + +async def test_codex_sdk(): + if os.environ.get("RUN_FABRIC_CODEX_INTEGRATION") != "1": + pytest.skip("set RUN_FABRIC_CODEX_INTEGRATION=1 to run") + if importlib.util.find_spec("openai_codex") is None: + pytest.fail("the openai-codex Python SDK is required") + if importlib.util.find_spec("nemo_fabric._native") is None: + pytest.fail("the nemo_fabric native extension is required (pip install -e .)") + await _run() + + +async def test_codex_sdk_with_relay(): + if os.environ.get("RUN_FABRIC_CODEX_RELAY_INTEGRATION") != "1": + pytest.skip("set RUN_FABRIC_CODEX_RELAY_INTEGRATION=1 to run") + if importlib.util.find_spec("openai_codex") is None: + pytest.fail("the openai-codex Python SDK is required") + if importlib.util.find_spec("nemo_fabric._native") is None: + pytest.fail("the nemo_fabric native extension is required (pip install -e .)") + relay_command = os.environ.get("FABRIC_TEST_NEMO_RELAY_COMMAND") or shutil.which( + "nemo-relay" + ) + if relay_command is None: + pytest.fail("the nemo-relay CLI is required") + await _run_relay(relay_command) + + +async def _run() -> None: + from examples.code_review_agent import BASE_DIR, codex_config + from nemo_fabric import Fabric + + config = codex_config() + nonce = f"fabric-{uuid.uuid4().hex[:8]}" + client = Fabric() + oneshot = await client.run( + config, + base_dir=BASE_DIR, + input="Reply with exactly: FABRIC_CODEX_ONESHOT_OK", + ) + assert oneshot["status"] == "succeeded", oneshot.to_mapping() + assert "fabric_codex_oneshot_ok" in oneshot["output"]["response"].lower(), ( + oneshot.to_mapping() + ) + assert oneshot["output"]["adapter"] == "sdk", oneshot.to_mapping() + assert "command" not in oneshot["output"], oneshot.to_mapping() + + async with await client.start_runtime( + config, + base_dir=BASE_DIR, + ) as runtime: + first = await runtime.invoke(input=f"Remember this value: {nonce}") + second = await runtime.invoke( + input="Reply with only the value I asked you to remember." + ) + + results = (first.to_mapping(), second.to_mapping()) + assert first["status"] == second["status"] == "succeeded", results + assert first["output"]["thread_id"] == second["output"]["thread_id"], results + assert nonce in second["output"]["response"], second.to_mapping() + assert first["output"]["events"], first.to_mapping() + assert second["output"]["usage"] is not None, second.to_mapping() + + +async def _run_relay(relay_command: str) -> None: + from examples.code_review_agent import BASE_DIR, codex_config, with_relay + from nemo_fabric import Fabric + + config = with_relay(codex_config()) + config.harness.settings["nemo_relay_command"] = relay_command + client = Fabric() + result = await client.run( + config, + base_dir=BASE_DIR, + input="Reply with exactly: FABRIC_CODEX_RELAY_OK", + ) + + mapping = result.to_mapping() + assert result["status"] == "succeeded", mapping + assert "fabric_codex_relay_ok" in result["output"]["response"].lower(), mapping + assert result["output"]["adapter"] == "sdk", mapping + assert result["output"]["relay_runtime"]["enabled"] is True, mapping + assert {item["kind"] for item in result["output"]["relay_artifacts"]} >= { + "atof", + "atif", + }, mapping + + nonce = f"fabric-relay-{uuid.uuid4().hex[:8]}" + async with await client.start_runtime(config, base_dir=BASE_DIR) as runtime: + first = await runtime.invoke(input=f"Remember this value: {nonce}") + second = await runtime.invoke( + input="Reply with only the value I asked you to remember." + ) + + results = (first.to_mapping(), second.to_mapping()) + assert first["status"] == second["status"] == "succeeded", results + assert first["output"]["thread_id"] == second["output"]["thread_id"], results + assert nonce in second["output"]["response"], second.to_mapping() + for turn in (first, second): + assert turn["output"]["relay_runtime"]["enabled"] is True, turn.to_mapping() + assert {item["kind"] for item in turn["output"]["relay_artifacts"]} >= { + "atof", + "atif", + }, turn.to_mapping() diff --git a/tests/e2e/test_codex_cli.py b/tests/e2e/test_codex_cli.py deleted file mode 100644 index 5fb154cfe..000000000 --- a/tests/e2e/test_codex_cli.py +++ /dev/null @@ -1,63 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Opt-in real Codex CLI smoke for Fabric one-shot and multi-turn runtimes. - -RUN_FABRIC_CODEX_INTEGRATION=1 pytest tests/e2e/test_codex_cli.py -""" - -from __future__ import annotations - -import importlib.util -import os -import shutil -import uuid - -import pytest - - -async def test_codex_cli(): - if os.environ.get("RUN_FABRIC_CODEX_INTEGRATION") != "1": - pytest.skip("set RUN_FABRIC_CODEX_INTEGRATION=1 to run") - if shutil.which("codex") is None: - pytest.fail("codex CLI is required") - if importlib.util.find_spec("nemo_fabric._native") is None: - pytest.fail("the nemo_fabric native extension is required (pip install -e .)") - await _run() - - -async def _run() -> None: - from examples.code_review_agent import BASE_DIR, codex_cli_config - from nemo_fabric import Fabric - - config = codex_cli_config() - nonce = f"fabric-{uuid.uuid4().hex[:8]}" - client = Fabric() - oneshot = await client.run( - config, - base_dir=BASE_DIR, - input="Reply with exactly: FABRIC_CODEX_ONESHOT_OK", - ) - assert oneshot["status"] == "succeeded", oneshot.to_mapping() - assert "fabric_codex_oneshot_ok" in oneshot["output"]["response"].lower(), ( - oneshot.to_mapping() - ) - assert "--ephemeral" not in oneshot["output"]["command"], oneshot.to_mapping() - - async with await client.start_runtime( - config, - base_dir=BASE_DIR, - ) as runtime: - first = await runtime.invoke(input=f"Remember this value: {nonce}") - second = await runtime.invoke( - input="Reply with only the value I asked you to remember." - ) - - results = (first.to_mapping(), second.to_mapping()) - assert first["status"] == second["status"] == "succeeded", results - assert first["output"]["thread_id"] == second["output"]["thread_id"], results - assert nonce in second["output"]["response"], second.to_mapping() - assert second["output"]["command"][-3:-1] == [ - "resume", - first["output"]["thread_id"], - ], second.to_mapping() diff --git a/tests/fixtures/file-config-agent/profiles/codex-cli.yaml b/tests/fixtures/file-config-agent/profiles/codex.yaml similarity index 66% rename from tests/fixtures/file-config-agent/profiles/codex-cli.yaml rename to tests/fixtures/file-config-agent/profiles/codex.yaml index 4b7582de4..9abca5c06 100644 --- a/tests/fixtures/file-config-agent/profiles/codex-cli.yaml +++ b/tests/fixtures/file-config-agent/profiles/codex.yaml @@ -2,17 +2,15 @@ # SPDX-License-Identifier: Apache-2.0 schema_version: fabric.profile/v1alpha1 -name: codex_cli -description: Test Codex CLI file-profile resolution. +name: codex +description: Test Codex SDK file-profile resolution. harness: - adapter_id: nvidia.fabric.codex.cli + adapter_id: nvidia.fabric.codex resolution: preinstalled settings: sandbox: workspace-write - skip_git_repo_check: true - config_overrides: - model_reasoning_effort: high + reasoning_effort: high models: default: @@ -27,11 +25,11 @@ mcp: null runtime: input_schema: text output_schema: message - artifacts: ./artifacts/codex-cli + artifacts: ./artifacts/codex environment: provider: local workspace: ./repos/my-service - artifacts: ./artifacts/codex-cli + artifacts: ./artifacts/codex telemetry: null diff --git a/tests/integrations/test_harbor_runner.py b/tests/integrations/test_harbor_runner.py index afd53fc8b..c92d91bd0 100644 --- a/tests/integrations/test_harbor_runner.py +++ b/tests/integrations/test_harbor_runner.py @@ -2,10 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio -import importlib.util import json import os -import tomllib from pathlib import Path import pytest @@ -28,12 +26,9 @@ pytestmark = pytest.mark.usefixtures("requires_harbor") def load_codex_adapter(): - path = ROOT / "adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/adapter.py" - spec = importlib.util.spec_from_file_location("fabric_codex_adapter", path) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module + from nemo_fabric_adapters.codex import adapter + + return adapter def test_runner_composes_harbor_values_on_an_independent_config(tmp_path): @@ -256,7 +251,7 @@ async def run_specs(): ] -def test_codex_adapter_maps_fabric_request_to_cli(tmp_path): +def test_codex_adapter_maps_fabric_request_to_sdk(tmp_path): adapter = load_codex_adapter() payload = { @@ -266,8 +261,7 @@ def test_codex_adapter_maps_fabric_request_to_cli(tmp_path): "harness": { "settings": { "sandbox": "workspace-write", - "skip_git_repo_check": True, - "config_overrides": {"model_reasoning_effort": "high"}, + "reasoning_effort": "high", } }, "models": { @@ -286,29 +280,10 @@ def test_codex_adapter_maps_fabric_request_to_cli(tmp_path): "request": {"input": "Fix the calculator."}, } - os.environ["CODEX_HOME"] = str(tmp_path) - profile_name = "fabric-harbor-test" - profile_path = tmp_path / f"{profile_name}.config.toml" - codex_settings = adapter.write_config_files(payload) - command = adapter.build_command( - payload, - codex_settings=codex_settings, - ) - - assert command == [ - "codex", - "exec", - "--json", - "--sandbox", - "workspace-write", - "--profile", - profile_name, - "--model", - "gpt-5.4", - "--skip-git-repo-check", - "-", - ] - assert tomllib.loads(profile_path.read_text(encoding="utf-8")) == {"model_reasoning_effort": "high"} + assert adapter.selected_model(payload) == "gpt-5.4" + assert adapter.sandbox(payload) == adapter.Sandbox.workspace_write + assert adapter._reasoning_effort(payload) == adapter.ReasoningEffort.high + assert adapter.thread_config(payload, relay=None) == {} assert adapter.resolve_cwd(payload) == tmp_path @@ -317,13 +292,12 @@ def test_codex_demo_uses_current_adapter_contract(): settings = config["harness"]["settings"] assert config["schema_version"] == "fabric.agent/v1alpha1" - assert config["harness"]["adapter_id"] == "nvidia.fabric.codex.cli" + assert config["harness"]["adapter_id"] == "nvidia.fabric.codex" assert settings["sandbox"] == "danger-full-access" - assert settings["skip_git_repo_check"] is True - assert settings["config_overrides"]["model_reasoning_effort"] == "high" + assert settings["reasoning_effort"] == "high" dockerfile = DEMO_DOCKERFILE.read_text(encoding="utf-8") assert "nemo-fabric[codex,harbor,hermes,relay,runtime]" in dockerfile - assert "@openai/codex@0.142.4" in dockerfile + assert "@openai/codex" not in dockerfile def test_harbor_demo_uses_complete_configs_without_profiles(): diff --git a/tests/python/test_code_review_example.py b/tests/python/test_code_review_example.py index d23e26a52..fad4ff493 100644 --- a/tests/python/test_code_review_example.py +++ b/tests/python/test_code_review_example.py @@ -12,7 +12,7 @@ from examples.code_review_agent import BASE_DIR from examples.code_review_agent import __main__ as main_module from examples.code_review_agent import base_config -from examples.code_review_agent import codex_cli_config +from examples.code_review_agent import codex_config from examples.code_review_agent import hermes_config from examples.code_review_agent import with_fabric_managed_github_mcp from examples.code_review_agent import with_native_otel @@ -28,7 +28,7 @@ def test_variant_builders_return_independent_complete_configs(): base = base_config() hermes = hermes_config() - codex = codex_cli_config() + codex = codex_config() for config in (base, hermes, codex): assert isinstance(config, FabricConfig) @@ -38,7 +38,7 @@ def test_variant_builders_return_independent_complete_configs(): assert hermes is not base assert hermes.harness is not base.harness - assert codex.harness.adapter_id == "nvidia.fabric.codex.cli" + assert codex.harness.adapter_id == "nvidia.fabric.codex" assert codex.mcp is None assert codex.skills is None assert base.mcp is not None @@ -76,7 +76,7 @@ def test_capability_and_telemetry_variants_do_not_mutate_their_input(): def test_variants_plan_without_file_profiles(): client = Fabric() - for config in (hermes_config(), codex_cli_config()): + for config in (hermes_config(), codex_config()): plan = client.plan(config, base_dir=BASE_DIR) assert plan.profiles == () assert plan.agent_name == "code-review-agent" @@ -86,7 +86,7 @@ def test_variants_plan_without_file_profiles(): def test_example_entrypoint_plans_without_starting_a_runtime(): cases = ( ([], "nvidia.fabric.hermes", False), - (["--variant", "codex-cli"], "nvidia.fabric.codex.cli", False), + (["--variant", "codex"], "nvidia.fabric.codex", False), (["--relay"], "nvidia.fabric.hermes", True), ) diff --git a/uv.lock b/uv.lock index ddef3aee1..350dcd2f2 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] +[manifest] +constraints = [{ name = "openai-codex-cli-bin", specifier = "==0.137.0a4" }] + [[package]] name = "aiohappyeyeballs" version = "2.6.2" @@ -2010,8 +2013,8 @@ source = { editable = "." } adapters-claude = [ { name = "nemo-fabric-adapters-claude" }, ] -adapters-codex-cli = [ - { name = "nemo-fabric-adapters-codex-cli" }, +adapters-codex = [ + { name = "nemo-fabric-adapters-codex" }, ] adapters-common = [ { name = "nemo-fabric-adapters-common" }, @@ -2026,7 +2029,7 @@ claude = [ { name = "nemo-fabric-adapters-claude" }, ] codex = [ - { name = "nemo-fabric-adapters-codex-cli" }, + { name = "nemo-fabric-adapters-codex" }, ] deepagents = [ { name = "nemo-fabric-adapters-deepagents" }, @@ -2050,7 +2053,7 @@ runtime = [ [package.dev-dependencies] adapters = [ { name = "nemo-fabric-adapters-claude" }, - { name = "nemo-fabric-adapters-codex-cli" }, + { name = "nemo-fabric-adapters-codex" }, { name = "nemo-fabric-adapters-common" }, { name = "nemo-fabric-adapters-deepagents" }, { name = "nemo-fabric-adapters-hermes", marker = "python_full_version < '3.14'" }, @@ -2082,8 +2085,8 @@ requires-dist = [ { name = "hermes-agent", marker = "python_full_version < '3.14' and extra == 'hermes'", specifier = ">=0.17.0" }, { name = "nemo-fabric-adapters-claude", marker = "extra == 'adapters-claude'", editable = "adapters/claude" }, { name = "nemo-fabric-adapters-claude", marker = "extra == 'claude'", editable = "adapters/claude" }, - { name = "nemo-fabric-adapters-codex-cli", marker = "extra == 'adapters-codex-cli'", editable = "adapters/codex-cli" }, - { name = "nemo-fabric-adapters-codex-cli", marker = "extra == 'codex'", editable = "adapters/codex-cli" }, + { name = "nemo-fabric-adapters-codex", marker = "extra == 'adapters-codex'", editable = "adapters/codex" }, + { name = "nemo-fabric-adapters-codex", marker = "extra == 'codex'", editable = "adapters/codex" }, { name = "nemo-fabric-adapters-common", marker = "extra == 'adapters-common'", editable = "adapters/common" }, { name = "nemo-fabric-adapters-deepagents", marker = "extra == 'adapters-deepagents'", editable = "adapters/deepagents" }, { name = "nemo-fabric-adapters-deepagents", marker = "extra == 'deepagents'", editable = "adapters/deepagents" }, @@ -2094,12 +2097,12 @@ requires-dist = [ { name = "pyyaml", marker = "extra == 'harbor'", specifier = ">=6.0" }, { name = "tomli-w", marker = "extra == 'relay'", specifier = "~=1.2" }, ] -provides-extras = ["adapters-common", "adapters-codex-cli", "adapters-claude", "adapters-deepagents", "adapters-hermes", "codex", "claude", "deepagents", "harbor", "hermes", "relay", "runtime"] +provides-extras = ["adapters-common", "adapters-codex", "adapters-claude", "adapters-deepagents", "adapters-hermes", "codex", "claude", "deepagents", "harbor", "hermes", "relay", "runtime"] [package.metadata.requires-dev] adapters = [ { name = "nemo-fabric-adapters-claude", editable = "adapters/claude" }, - { name = "nemo-fabric-adapters-codex-cli", editable = "adapters/codex-cli" }, + { name = "nemo-fabric-adapters-codex", editable = "adapters/codex" }, { name = "nemo-fabric-adapters-common", editable = "adapters/common" }, { name = "nemo-fabric-adapters-deepagents", editable = "adapters/deepagents" }, { name = "nemo-fabric-adapters-hermes", marker = "python_full_version < '3.14'", editable = "adapters/hermes" }, @@ -2143,17 +2146,19 @@ requires-dist = [ ] [[package]] -name = "nemo-fabric-adapters-codex-cli" +name = "nemo-fabric-adapters-codex" version = "0.1.0" -source = { editable = "adapters/codex-cli" } +source = { editable = "adapters/codex" } dependencies = [ { name = "nemo-fabric-adapters-common" }, + { name = "openai-codex" }, { name = "tomli-w" }, ] [package.metadata] requires-dist = [ { name = "nemo-fabric-adapters-common", editable = "adapters/common" }, + { name = "openai-codex", specifier = "==0.1.0b3" }, { name = "tomli-w", specifier = "~=1.2" }, ] @@ -2309,6 +2314,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" }, ] +[[package]] +name = "openai-codex" +version = "0.1.0b3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openai-codex-cli-bin" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1c/1e5e8b83ea72164d32b1f4e67fc703c8b83591f498a7aaf96f39d352b453/openai_codex-0.1.0b3.tar.gz", hash = "sha256:b76b7afe97953ac65648e9b8ca116b5ff273de91086549bd7ec88037cdc16cab", size = 58995, upload-time = "2026-06-03T19:17:34.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ef/f77037d9ccde80a688a17a06aea5a56813ad9c365d49b3f1c7913422af8b/openai_codex-0.1.0b3-py3-none-any.whl", hash = "sha256:8d1f9d346667aeecb435c6a45d0edb3f016187276ec452cf8094d813896276c4", size = 65639, upload-time = "2026-06-03T19:17:33.208Z" }, +] + +[[package]] +name = "openai-codex-cli-bin" +version = "0.137.0a4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/60/af73ef1676cd477fa83ed4b889bf3b57c63c47dd87025b2cc4262793cff6/openai_codex_cli_bin-0.137.0a4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b33c3917e0b58d527ee11a11a78ad390f7d8e6aa25577dd21665ab3c8bf5cf9a", size = 94300191, upload-time = "2026-06-03T18:44:36.312Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/d1a5f8c87176e00ef6a85798794f4530f5eb04e5a1a13468b5b3c3a361f9/openai_codex_cli_bin-0.137.0a4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3d0f0bc5becc88c61952fbfa9bd792ac9d74fa78b3a6bd40f545b612048b07eb", size = 83924479, upload-time = "2026-06-03T18:44:40.854Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3c/fc00bcdc0c302208317d5eb1d0bfaab3024f351cd0121400f19baa6b19aa/openai_codex_cli_bin-0.137.0a4-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:2f1656339e2736868c4cce59f6d9e5c633879123687169b03b1137d42bf2c11a", size = 83363315, upload-time = "2026-06-03T18:44:44.851Z" }, + { url = "https://files.pythonhosted.org/packages/ec/09/39362e944ebeb12fcbfb86881fbb4dd6e806f77f7541c1f1f993bb9351a0/openai_codex_cli_bin-0.137.0a4-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:6454f838d44c56c1ed07a29b391fa412785e5dd2ffd06db0b62e62478c19bb64", size = 90611239, upload-time = "2026-06-03T18:44:49.338Z" }, + { url = "https://files.pythonhosted.org/packages/fa/38/87b1247fdfe95cddce7f7fe8331d6843cf037e14292c0f5004e23247133b/openai_codex_cli_bin-0.137.0a4-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f5ae7401d00c65d56a75d9645d7bf87d809566a12d238e4b2a8b328a02f2316e", size = 83363315, upload-time = "2026-06-03T18:44:53.428Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c4/3c693ad07e587f6b3a28128c417f2e831d81a40cdbd85c0e5f0f36aaff82/openai_codex_cli_bin-0.137.0a4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3dcec1e649448be498d6e7ec0e1f71dca83efa76063d90890dafb41e987069b7", size = 90611238, upload-time = "2026-06-03T18:44:57.612Z" }, + { url = "https://files.pythonhosted.org/packages/9e/26/81e037066b9b8d312a6f9e09015e452ce17630d5ab88e02a4c1d9503e4e8/openai_codex_cli_bin-0.137.0a4-py3-none-win_amd64.whl", hash = "sha256:9e13bf68e18e36bd3a0efd51213281c83e9f6ec22bdb7a45bd2e0211822733a9", size = 94744969, upload-time = "2026-06-03T18:45:02.23Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a3/952bc2a5d62373a51fea161effe3b338b3417c2f6e65fe467ed91b205e2b/openai_codex_cli_bin-0.137.0a4-py3-none-win_arm64.whl", hash = "sha256:5ec4303ca2dcb5f838e0de3ca7f44050b6bcdd41d281a178c3a1420a985a515d", size = 86963504, upload-time = "2026-06-03T18:45:07.131Z" }, +] + [[package]] name = "orjson" version = "3.11.9" From 41da96b170aeadece389120c931e2af4aa009bf5 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Wed, 15 Jul 2026 14:11:55 -0700 Subject: [PATCH 02/10] fix(codex): preserve telemetry child environment Signed-off-by: Ajay Thorve --- adapters/codex/README.md | 2 + .../src/nemo_fabric_adapters/codex/adapter.py | 23 +- tests/adapters/test_codex_adapter.py | 267 ++++++++++-------- tests/e2e/test_codex.py | 2 +- 4 files changed, 176 insertions(+), 118 deletions(-) diff --git a/adapters/codex/README.md b/adapters/codex/README.md index d8f66f8dd..63261373c 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -12,6 +12,8 @@ app-server runtime and typed JSON-RPC protocol. ## Install +Build the local wheels and install the Codex adapter: + ```bash just wheels python -m pip install --find-links dist "nemo-fabric[codex]" diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py index 737f2c68a..5f734c818 100644 --- a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -259,10 +259,29 @@ def _optional_string(settings: dict[str, Any], name: str) -> str | None: def child_environment( payload: dict[str, Any], *, relay_gateway_url: str | None = None ) -> dict[str, str]: - values = {name: "" for name in os.environ} + values = dict.fromkeys(os.environ, "") values.update( - {name: value for name in INHERITED_ENV_NAMES if (value := os.environ.get(name))} + {name: os.environ[name] for name in INHERITED_ENV_NAMES if name in os.environ} ) + telemetry = common_utils.runtime_context(payload).get("telemetry") + if telemetry is None: + telemetry = {} + if not isinstance(telemetry, dict): + raise AdapterInputError( + "codex_invalid_request", "runtime_context.telemetry must be a mapping" + ) + telemetry_env = telemetry.get("env") + if telemetry_env is None: + telemetry_env = {} + if not isinstance(telemetry_env, dict) or any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in telemetry_env.items() + ): + raise AdapterInputError( + "codex_invalid_request", + "runtime_context.telemetry.env must contain strings", + ) + values.update(telemetry_env) model_config = _selected_model_config(payload) api_key_env = model_config.get("api_key_env") if isinstance(api_key_env, str) and api_key_env in os.environ: diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index bd2438d1d..0427b93aa 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import asyncio import json import os from pathlib import Path @@ -10,6 +11,7 @@ import pytest from nemo_fabric import Fabric from nemo_fabric_adapters.codex import adapter +from openai_codex import AsyncCodex, AsyncThread, AsyncTurnHandle from openai_codex.types import TurnStatus @@ -73,73 +75,80 @@ def successful_result(response="done"): ) -class FakeTurnHandle: - def __init__(self, result=None): - self.result = result or successful_result() - self.interrupted = False - - async def run(self): - if isinstance(self.result, BaseException): - raise self.result - return self.result - - async def interrupt(self): - self.interrupted = True - - -class FakeThread: - def __init__(self, thread_id, result=None): - self.id = thread_id - self.handle = FakeTurnHandle(result) - self.turn_calls = [] - - async def turn(self, prompt, **kwargs): - self.turn_calls.append((prompt, kwargs)) - return self.handle - - -class FakeAsyncCodex: - instances = [] - next_thread_id = "thread-123" - next_result = None - - def __init__(self, config): - self.config = config - self.start_calls = [] - self.resume_calls = [] - self.closed = False - self.thread = None - type(self).instances.append(self) - - async def thread_start(self, **kwargs): - self.start_calls.append(kwargs) - self.thread = FakeThread(type(self).next_thread_id, type(self).next_result) - return self.thread - - async def thread_resume(self, thread_id, **kwargs): - self.resume_calls.append((thread_id, kwargs)) - self.thread = FakeThread(thread_id, type(self).next_result) - return self.thread - - async def close(self): - self.closed = True - - -@pytest.fixture(name="fake_codex") -def fake_codex_fixture(monkeypatch): - FakeAsyncCodex.instances = [] - FakeAsyncCodex.next_thread_id = "thread-123" - FakeAsyncCodex.next_result = None - monkeypatch.setattr(adapter, "AsyncCodex", FakeAsyncCodex) - return FakeAsyncCodex +def mock_turn_handle(result=None): + mock_handle = MagicMock(spec=AsyncTurnHandle) + outcome = successful_result() if result is None else result + if isinstance(outcome, BaseException): + mock_handle.run.side_effect = outcome + else: + mock_handle.run.return_value = outcome + mock_handle.interrupted = False + + async def mark_interrupted(): + mock_handle.interrupted = True + + mock_handle.interrupt.side_effect = mark_interrupted + return mock_handle + + +def mock_thread(thread_id, result=None): + mock_sdk_thread = MagicMock(spec=AsyncThread) + mock_sdk_thread.id = thread_id + mock_sdk_thread.handle = mock_turn_handle(result) + mock_sdk_thread.turn.return_value = mock_sdk_thread.handle + return mock_sdk_thread + + +@pytest.fixture(name="mock_codex") +def mock_codex_fixture(monkeypatch): + mock_codex = MagicMock(spec=AsyncCodex) + mock_codex.instances = [] + mock_codex.next_thread_id = "thread-123" + mock_codex.next_result = None + mock_codex.next_thread = None + mock_codex.resume_thread_id = None + + def build_client(*, config): + mock_client = MagicMock(spec=AsyncCodex) + mock_client.config = config + mock_client.closed = False + mock_client.thread = None + + async def close(): + mock_client.closed = True + + async def thread_start(**_kwargs): + mock_client.thread = ( + mock_codex.next_thread + if mock_codex.next_thread is not None + else mock_thread(mock_codex.next_thread_id, mock_codex.next_result) + ) + return mock_client.thread + + async def thread_resume(thread_id, **_kwargs): + resumed_thread_id = mock_codex.resume_thread_id or thread_id + mock_client.thread = mock_thread( + resumed_thread_id, mock_codex.next_result + ) + return mock_client.thread + + mock_client.close.side_effect = close + mock_client.thread_start.side_effect = thread_start + mock_client.thread_resume.side_effect = thread_resume + mock_codex.instances.append(mock_client) + return mock_client + + mock_codex.side_effect = build_client + monkeypatch.setattr(adapter, "AsyncCodex", mock_codex) + return mock_codex def test_sdk_oneshot_uses_native_thread_and_turn_contract( - codex_payload, fake_codex, monkeypatch, tmp_path + codex_payload, mock_codex, tmp_path ): - monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex-home")) - monkeypatch.setenv("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", "parent-codex") - monkeypatch.setenv("FABRIC_UNRELATED_SECRET", "do-not-forward") + os.environ["CODEX_HOME"] = str(tmp_path / "codex-home") + os.environ["CODEX_INTERNAL_ORIGINATOR_OVERRIDE"] = "parent-codex" + os.environ["FABRIC_UNRELATED_SECRET"] = "do-not-forward" codex_payload["effective_config"]["config"]["harness"]["settings"]["env"] = { "CODEX_EXPLICIT": "forward-me" } @@ -156,7 +165,7 @@ def test_sdk_oneshot_uses_native_thread_and_turn_contract( assert "command" not in output assert "returncode" not in output - client = fake_codex.instances[0] + client = mock_codex.instances[0] assert client.closed is True assert client.config.codex_bin is None assert client.config.launch_args_override is None @@ -170,7 +179,7 @@ def test_sdk_oneshot_uses_native_thread_and_turn_contract( == "codex_python_sdk" ) assert client.config.env["FABRIC_UNRELATED_SECRET"] == "" - start = client.start_calls[0] + start = client.thread_start.await_args.kwargs assert start["model"] == "gpt-5.4" assert start["model_provider"] == "openai" assert start["sandbox"] == adapter.Sandbox.workspace_write @@ -178,13 +187,13 @@ def test_sdk_oneshot_uses_native_thread_and_turn_contract( "features": {"web_search": False}, "model_reasoning_effort": "high", } - assert client.thread.turn_calls == [ - ("Inspect the change.", {"effort": None, "output_schema": None}) - ] + client.thread.turn.assert_awaited_once_with( + "Inspect the change.", effort=None, output_schema=None + ) def test_runtime_resumes_sdk_thread_across_invocations( - codex_payload, fake_codex + codex_payload, mock_codex ): first = adapter.run(codex_payload) codex_payload["runtime_context"]["invocation_id"] = "invocation-2" @@ -192,9 +201,9 @@ def test_runtime_resumes_sdk_thread_across_invocations( second = adapter.run(codex_payload) assert first["thread_id"] == second["thread_id"] == "thread-123" - assert fake_codex.instances[0].start_calls - assert fake_codex.instances[1].resume_calls[0][0] == "thread-123" - assert fake_codex.instances[1].thread.turn_calls[0][0] == "Continue." + mock_codex.instances[0].thread_start.assert_awaited_once() + assert mock_codex.instances[1].thread_resume.await_args.args[0] == "thread-123" + assert mock_codex.instances[1].thread.turn.await_args.args[0] == "Continue." state = json.loads( adapter.runtime_state_path(codex_payload, "runtime-1").read_text( encoding="utf-8" @@ -217,9 +226,9 @@ def test_runtime_rejects_corrupt_thread_state(codex_payload): def test_failed_sdk_turn_is_normalized_and_transport_is_closed( - codex_payload, fake_codex + codex_payload, mock_codex ): - fake_codex.next_result = RuntimeError("model request failed") + mock_codex.next_result = RuntimeError("model request failed") output = adapter.run(codex_payload) @@ -228,15 +237,15 @@ def test_failed_sdk_turn_is_normalized_and_transport_is_closed( "message": "model request failed", "retryable": False, } - assert fake_codex.instances[0].closed is True + assert mock_codex.instances[0].closed is True assert not adapter.runtime_state_path(codex_payload, "runtime-1").exists() def test_incomplete_sdk_turn_is_failed_without_persisting_thread( - codex_payload, fake_codex + codex_payload, mock_codex ): result = successful_result(response=None) - fake_codex.next_result = result + mock_codex.next_result = result output = adapter.run(codex_payload) @@ -245,7 +254,7 @@ def test_incomplete_sdk_turn_is_failed_without_persisting_thread( assert not adapter.runtime_state_path(codex_payload, "runtime-1").exists() -def test_selected_model_rejects_unsupported_provider(codex_payload, fake_codex): +def test_selected_model_rejects_unsupported_provider(codex_payload, mock_codex): model = codex_payload["effective_config"]["config"]["models"]["default"] model["provider"] = "nvidia" @@ -253,29 +262,23 @@ def test_selected_model_rejects_unsupported_provider(codex_payload, fake_codex): assert output["error"]["code"] == "codex_invalid_configuration" assert "provider must be openai" in output["error"]["message"] - assert fake_codex.instances == [] + mock_codex.assert_not_called() def test_resume_rejects_changed_sdk_thread_identity( - codex_payload, fake_codex, monkeypatch + codex_payload, mock_codex ): adapter.save_thread_id(codex_payload, "runtime-1", "thread-persisted") - - async def thread_resume(self, thread_id, **kwargs): - self.resume_calls.append((thread_id, kwargs)) - self.thread = FakeThread("thread-replaced") - return self.thread - - monkeypatch.setattr(fake_codex, "thread_resume", thread_resume) + mock_codex.resume_thread_id = "thread-replaced" output = adapter.run(codex_payload) assert output["error"]["code"] == "codex_thread_mismatch" - assert fake_codex.instances[0].closed is True + assert mock_codex.instances[0].closed is True def test_relay_uses_gateway_and_request_scoped_sdk_config( - codex_payload, fake_codex, monkeypatch, tmp_path + codex_payload, mock_codex, monkeypatch, tmp_path ): codex_payload["telemetry_plan"] = { "providers": ["relay"], @@ -302,13 +305,14 @@ def test_relay_uses_gateway_and_request_scoped_sdk_config( adapter.relay_gateway, "start_relay_gateway", start_gateway ) monkeypatch.setattr(adapter.relay_gateway, "stop_relay_gateway", stop_gateway) - monkeypatch.setenv("FABRIC_RELAY_CONFIG_PATH", str(tmp_path / "relay.json")) + os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(tmp_path / "relay.json") output = adapter.run(codex_payload) - client = fake_codex.instances[0] - config = client.start_calls[0]["config"] - assert client.start_calls[0]["model_provider"] == "openai" + client = mock_codex.instances[0] + start = client.thread_start.await_args.kwargs + config = start["config"] + assert start["model_provider"] == "openai" assert client.config.env["NEMO_RELAY_GATEWAY_URL"] == gateway.url assert config["bypass_hook_trust"] is True assert config["features"]["hooks"] is True @@ -375,8 +379,9 @@ def test_prepare_relay_reuses_one_resolved_executable( ) +@pytest.mark.usefixtures("mock_codex") def test_relay_cleanup_failure_changes_success_to_failure( - codex_payload, fake_codex, monkeypatch, tmp_path + codex_payload, monkeypatch, tmp_path ): gateway = adapter.relay_gateway.RelayGatewayLaunch( executable=tmp_path / "nemo-relay", @@ -408,7 +413,7 @@ def test_relay_cleanup_failure_changes_success_to_failure( def test_native_sdk_controls_and_telemetry_are_request_scoped( - codex_payload, fake_codex + codex_payload, mock_codex ): settings = codex_payload["effective_config"]["config"]["harness"]["settings"] settings.update( @@ -449,8 +454,8 @@ def test_native_sdk_controls_and_telemetry_are_request_scoped( output = adapter.run(codex_payload) assert output["failed"] is False - client = fake_codex.instances[0] - start = client.start_calls[0] + client = mock_codex.instances[0] + start = client.thread_start.await_args.kwargs assert start["personality"] == adapter.Personality.pragmatic assert start["service_name"] == "fabric-codex-test" assert start["config"]["otel"] == { @@ -462,36 +467,28 @@ def test_native_sdk_controls_and_telemetry_are_request_scoped( } }, } - _, turn = client.thread.turn_calls[0] + turn = client.thread.turn.await_args.kwargs assert turn["effort"] == adapter.ReasoningEffort.xhigh assert turn["output_schema"]["required"] == ["summary"] def test_timeout_interrupts_native_turn_and_closes_sdk( - codex_payload, fake_codex, monkeypatch + codex_payload, mock_codex ): - class BlockingHandle(FakeTurnHandle): - async def run(self): - await __import__("asyncio").sleep(60) - - class BlockingThread(FakeThread): - def __init__(self, thread_id, result=None): - super().__init__(thread_id, result) - self.handle = BlockingHandle() + mock_blocking_thread = mock_thread("thread-timeout") - async def thread_start(self, **kwargs): - self.start_calls.append(kwargs) - self.thread = BlockingThread("thread-timeout") - return self.thread + async def block(): + await asyncio.sleep(60) - monkeypatch.setattr(fake_codex, "thread_start", thread_start) + mock_blocking_thread.handle.run.side_effect = block + mock_codex.next_thread = mock_blocking_thread codex_payload["effective_config"]["config"]["harness"]["settings"][ "timeout_seconds" ] = 0.01 output = adapter.run(codex_payload) - client = fake_codex.instances[0] + client = mock_codex.instances[0] assert output["error"]["code"] == "codex_timed_out" assert client.thread.handle.interrupted is True assert client.closed is True @@ -550,10 +547,50 @@ def test_codex_config_resolves_sdk_adapter(): assert not unsupported.get("mcp_servers") -def test_environment_does_not_mutate_parent(codex_payload, monkeypatch): - monkeypatch.setenv("FABRIC_UNRELATED_SECRET", "parent-value") +def test_environment_does_not_mutate_parent(codex_payload): + os.environ["FABRIC_UNRELATED_SECRET"] = "parent-value" child = adapter.child_environment(codex_payload) assert child["FABRIC_UNRELATED_SECRET"] == "" assert os.environ["FABRIC_UNRELATED_SECRET"] == "parent-value" + + +def test_environment_preserves_runtime_telemetry_env(codex_payload): + codex_payload["runtime_context"]["telemetry"] = { + "env": { + "FABRIC_RELAY_ENABLED": "true", + "FABRIC_RELAY_CONFIG_PATH": "/tmp/relay.json", + "CODEX_EXPLICIT": "telemetry", + } + } + codex_payload["effective_config"]["config"]["harness"]["settings"]["env"] = { + "CODEX_EXPLICIT": "configured" + } + os.environ["FABRIC_RELAY_CONFIG_PATH"] = "/tmp/parent-relay.json" + + child = adapter.child_environment(codex_payload) + + assert child["FABRIC_RELAY_ENABLED"] == "true" + assert child["FABRIC_RELAY_CONFIG_PATH"] == "/tmp/relay.json" + assert child["CODEX_EXPLICIT"] == "configured" + + +@pytest.mark.parametrize( + "telemetry_env", + [ + [], + {1: "value"}, + {"OTEL_EXPORTER_OTLP_ENDPOINT": 4318}, + ], +) +def test_environment_rejects_non_string_runtime_telemetry_env( + codex_payload, telemetry_env +): + codex_payload["runtime_context"]["telemetry"] = {"env": telemetry_env} + + with pytest.raises( + adapter.AdapterInputError, + match="runtime_context.telemetry.env must contain strings", + ): + adapter.child_environment(codex_payload) diff --git a/tests/e2e/test_codex.py b/tests/e2e/test_codex.py index 64cb9dbc1..41e4b6c34 100644 --- a/tests/e2e/test_codex.py +++ b/tests/e2e/test_codex.py @@ -3,7 +3,7 @@ """Opt-in real Codex SDK integration gates for Fabric runtime behavior. -RUN_FABRIC_CODEX_INTEGRATION=1 pytest tests/e2e/test_codex.py +RUN_FABRIC_CODEX_INTEGRATION=1 uv run pytest tests/e2e/test_codex.py """ from __future__ import annotations From 9454d1d2624d90509f329e646ed9179353dd8ea3 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Wed, 15 Jul 2026 14:18:06 -0700 Subject: [PATCH 03/10] test(codex): cover telemetry mapping validation Signed-off-by: Ajay Thorve --- tests/adapters/test_codex_adapter.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 0427b93aa..cf2a7519c 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -591,6 +591,19 @@ def test_environment_rejects_non_string_runtime_telemetry_env( with pytest.raises( adapter.AdapterInputError, - match="runtime_context.telemetry.env must contain strings", + match=r"runtime_context\.telemetry\.env must contain strings", + ): + adapter.child_environment(codex_payload) + + +@pytest.mark.parametrize("telemetry", [[], "invalid"]) +def test_environment_rejects_non_mapping_runtime_telemetry( + codex_payload, telemetry +): + codex_payload["runtime_context"]["telemetry"] = telemetry + + with pytest.raises( + adapter.AdapterInputError, + match=r"runtime_context\.telemetry must be a mapping", ): adapter.child_environment(codex_payload) From 73b8c6e48e40e8448ea803c61e2dd779c59a6f80 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Thu, 16 Jul 2026 00:50:17 -0700 Subject: [PATCH 04/10] fix(relay): align SDK gateway contract Signed-off-by: Ajay Thorve --- .../nemo_fabric_adapters/claude/adapter.py | 6 +- .../src/nemo_fabric_adapters/codex/adapter.py | 30 ++++--- .../common/relay_gateway.py | 25 +++++- .../common/relay_hooks.py | 84 ++++++++++--------- .../test_adapters_common_relay_gateway.py | 39 ++++++--- .../test_adapters_common_relay_hooks.py | 18 ++-- tests/adapters/test_claude_adapter.py | 16 +++- tests/adapters/test_codex_adapter.py | 26 +++++- tests/e2e/test_codex.py | 43 ++++++++++ 9 files changed, 199 insertions(+), 88 deletions(-) diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index 144639748..77452724e 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -412,14 +412,12 @@ def prepare_claude_relay(payload: dict[str, Any]) -> ClaudeRelaySettings | None: ) from error try: - observability_version = relay_gateway.relay_cli_observability_version( - executable - ) + relay_contract = relay_gateway.relay_cli_contract(executable) plugin_config = common_utils.load_relay_plugin_config(payload) config_path, plugin_config_path = common_utils.write_relay_configs( relay_config={"agents": {"claude": {"command": "claude"}}}, plugin_config=plugin_config, - observability_version=observability_version, + observability_version=relay_contract.observability_version, ) except ( OSError, diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py index 5f734c818..b1af468e2 100644 --- a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -479,9 +479,7 @@ def prepare_codex_relay(payload: dict[str, Any]) -> CodexRelaySettings | None: ) from error try: - observability_version = relay_gateway.relay_cli_observability_version( - executable - ) + relay_contract = relay_gateway.relay_cli_contract(executable) plugin_config = common_utils.load_relay_plugin_config(payload) config_path, plugin_config_path = common_utils.write_relay_configs( # The SDK owns Codex execution. Relay needs only gateway defaults and @@ -489,7 +487,7 @@ def prepare_codex_relay(payload: dict[str, Any]) -> CodexRelaySettings | None: # a misleading dependency on the removed Codex CLI launch path. relay_config={}, plugin_config=plugin_config, - observability_version=observability_version, + observability_version=relay_contract.observability_version, ) except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as error: raise AdapterRelayError( @@ -531,12 +529,17 @@ def thread_config( _merge_config( config, { - # Keep Codex's reserved built-in provider so the SDK preserves - # its supported ChatGPT/API-key auth and host metadata paths. - # Relay is an OpenAI transport proxy, not a distinct model - # provider, so only redirect the built-in provider's base URL. + # Keep the SDK-selected built-in provider so Codex retains its + # native API-key and ChatGPT authentication behavior. Relay is + # only the transport endpoint for this invocation. "openai_base_url": relay.gateway.url, - "features": {"hooks": True}, + "features": { + "hooks": True, + # Relay disables delegated multi-agent execution because + # Codex encrypts delegated task content before it reaches + # the gateway, making those spans opaque. + "multi_agent_v2": {"enabled": False}, + }, "hooks": relay_hooks.render_relay_hooks( "codex", relay.gateway.executable )["hooks"], @@ -552,7 +555,14 @@ def thread_config( def sdk_config( payload: dict[str, Any], relay: CodexRelaySettings | None ) -> CodexConfig: + codex_bin = _optional_string(_settings(payload), "codex_bin") + if codex_bin is not None: + path = Path(codex_bin).expanduser() + if not path.is_absolute(): + path = Path(common_utils.config_root(payload)) / path + codex_bin = str(path.resolve()) return CodexConfig( + codex_bin=codex_bin, cwd=str(resolve_cwd(payload)), env=child_environment( payload, @@ -770,8 +780,6 @@ async def invoke_codex_sdk( settings, "developer_instructions" ), "model": selected_model(payload), - # Keep the SDK's built-in provider authoritative. Relay-enabled - # execution changes only its request-scoped base URL. "model_provider": "openai", "personality": _personality(payload), "sandbox": sandbox(payload), diff --git a/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py b/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py index b4360031d..0dc1ff585 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py +++ b/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py @@ -20,6 +20,8 @@ RELAY_HEALTH_TIMEOUT_SECONDS = 10.0 RELAY_STOP_TIMEOUT_SECONDS = 5.0 RELAY_VERSION_TIMEOUT_SECONDS = 5.0 +RELAY_MINIMUM_VERSION = (0, 6, 0) +RELAY_MAXIMUM_VERSION = (0, 7, 0) class RelayGatewayError(RuntimeError): @@ -37,6 +39,14 @@ class RelayGatewayLaunch: log_path: Path +@dataclass(frozen=True) +class RelayCliContract: + """Versioned external Relay CLI contract consumed by Fabric adapters.""" + + version: tuple[int, int, int] + observability_version: int + + def resolve_relay_command(config_root: Path, value: str | Path) -> Path: """Resolve the configured Relay CLI to one absolute executable path.""" @@ -59,8 +69,8 @@ def find_available_tcp_port(host: str = "127.0.0.1") -> int: return int(listener.getsockname()[1]) -def relay_cli_observability_version(executable: Path) -> int: - """Return the observability config version accepted by a Relay CLI.""" +def relay_cli_contract(executable: Path) -> RelayCliContract: + """Resolve and validate the external Relay CLI contract once per invocation.""" try: completed = subprocess.run( @@ -78,8 +88,15 @@ def relay_cli_observability_version(executable: Path) -> int: match = re.search(r"\b(\d+)\.(\d+)\.(\d+)", completed.stdout) if completed.returncode != 0 or match is None: raise RelayGatewayError("NeMo Relay CLI version could not be determined") - major, minor, _ = (int(value) for value in match.groups()) - return 2 if (major, minor) >= (0, 6) else 1 + major, minor, patch = (int(value) for value in match.groups()) + version = (major, minor, patch) + if not RELAY_MINIMUM_VERSION <= version < RELAY_MAXIMUM_VERSION: + raise RelayGatewayError( + "unsupported NeMo Relay CLI version " + f"{'.'.join(str(value) for value in version)}; " + "Fabric requires >=0.6.0,<0.7.0" + ) + return RelayCliContract(version=version, observability_version=2) def wait_for_relay_gateway( diff --git a/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py b/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py index 5dc86f503..56b8d2378 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py +++ b/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py @@ -12,42 +12,53 @@ RelayHookAgent = Literal["claude", "codex"] -CLAUDE_RELAY_HOOK_EVENTS = ( - "SessionStart", - "UserPromptSubmit", - "UserPromptExpansion", - "PreToolUse", - "PostToolUse", - "PostToolUseFailure", - "PermissionRequest", - "SubagentStart", - "SubagentStop", - "Notification", - "Stop", - "PreCompact", - "PostCompact", - "SessionEnd", -) -CODEX_RELAY_HOOK_EVENTS = ( - "SessionStart", - "UserPromptSubmit", - "PreToolUse", - "PostToolUse", - "PermissionRequest", - "SubagentStart", - "SubagentStop", - "Stop", - "PreCompact", - "PostCompact", -) -RELAY_TOOL_HOOK_EVENTS = frozenset( - { +RELAY_HOOK_EVENTS: dict[RelayHookAgent, tuple[str, ...]] = { + "claude": ( + "SessionStart", + "UserPromptSubmit", + "UserPromptExpansion", "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest", - } -) + "SubagentStart", + "SubagentStop", + "Notification", + "Stop", + "PreCompact", + "PostCompact", + "SessionEnd", + ), + "codex": ( + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Stop", + "PreCompact", + "PostCompact", + ), +} +RELAY_TOOL_HOOK_EVENTS = { + "claude": frozenset( + { + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionRequest", + } + ), + "codex": frozenset( + { + "PreToolUse", + "PostToolUse", + "PermissionRequest", + } + ), +} def render_relay_hooks( @@ -61,12 +72,7 @@ def render_relay_hooks( command = f"{shlex.quote(str(executable))} hook-forward {agent}" hooks: dict[str, list[dict[str, Any]]] = {} - events = ( - CLAUDE_RELAY_HOOK_EVENTS - if agent == "claude" - else CODEX_RELAY_HOOK_EVENTS - ) - for event in events: + for event in RELAY_HOOK_EVENTS[agent]: group: dict[str, Any] = { "hooks": [ { @@ -76,7 +82,7 @@ def render_relay_hooks( } ] } - if event in RELAY_TOOL_HOOK_EVENTS: + if event in RELAY_TOOL_HOOK_EVENTS[agent]: group["matcher"] = "*" hooks[event] = [group] return {"hooks": hooks} diff --git a/tests/adapters/test_adapters_common_relay_gateway.py b/tests/adapters/test_adapters_common_relay_gateway.py index 251dd38bd..58b527d36 100644 --- a/tests/adapters/test_adapters_common_relay_gateway.py +++ b/tests/adapters/test_adapters_common_relay_gateway.py @@ -45,15 +45,14 @@ def test_resolve_relay_command_rejects_missing_executable(monkeypatch, tmp_path) @pytest.mark.parametrize( - ("output", "expected"), + ("output", "expected_version"), [ - ("nemo-relay 0.5.0\n", 1), - ("nemo-relay 0.6.0-alpha.20260714\n", 2), - ("nemo-relay 1.0.0\n", 2), + ("nemo-relay 0.6.0-alpha.20260714\n", (0, 6, 0)), + ("nemo-relay 0.6.99\n", (0, 6, 99)), ], ) -def test_relay_cli_observability_version_selects_compatible_contract( - monkeypatch, tmp_path, output, expected +def test_relay_cli_contract_selects_compatible_contract( + monkeypatch, tmp_path, output, expected_version ): monkeypatch.setattr( relay_gateway.subprocess, @@ -61,13 +60,31 @@ def test_relay_cli_observability_version_selects_compatible_contract( MagicMock(return_value=subprocess.CompletedProcess([], 0, stdout=output)), ) - assert ( - relay_gateway.relay_cli_observability_version(tmp_path / "nemo-relay") - == expected + assert relay_gateway.relay_cli_contract( + tmp_path / "nemo-relay" + ) == relay_gateway.RelayCliContract( + version=expected_version, + observability_version=2, ) -def test_relay_cli_observability_version_rejects_unparseable_output( +@pytest.mark.parametrize("output", ["nemo-relay 0.5.9", "nemo-relay 0.7.0"]) +def test_relay_cli_contract_rejects_unsupported_version( + monkeypatch, tmp_path, output +): + monkeypatch.setattr( + relay_gateway.subprocess, + "run", + MagicMock(return_value=subprocess.CompletedProcess([], 0, stdout=output)), + ) + + with pytest.raises( + relay_gateway.RelayGatewayError, match="Fabric requires >=0.6.0,<0.7.0" + ): + relay_gateway.relay_cli_contract(tmp_path / "nemo-relay") + + +def test_relay_cli_contract_rejects_unparseable_output( monkeypatch, tmp_path ): monkeypatch.setattr( @@ -79,7 +96,7 @@ def test_relay_cli_observability_version_rejects_unparseable_output( with pytest.raises( relay_gateway.RelayGatewayError, match="version could not be determined" ): - relay_gateway.relay_cli_observability_version(tmp_path / "nemo-relay") + relay_gateway.relay_cli_contract(tmp_path / "nemo-relay") def test_start_relay_gateway_captures_logs_and_waits_for_health(monkeypatch, tmp_path): diff --git a/tests/adapters/test_adapters_common_relay_hooks.py b/tests/adapters/test_adapters_common_relay_hooks.py index 4d89f24ff..c2c573089 100644 --- a/tests/adapters/test_adapters_common_relay_hooks.py +++ b/tests/adapters/test_adapters_common_relay_hooks.py @@ -63,18 +63,14 @@ def test_render_relay_hooks_matches_relay_agent_contract(agent, expected_events) ] } ] - assert { + expected_matchers = { event for event, groups in hooks.items() if groups[0].get("matcher") == "*" - } == ( - { - "PreToolUse", - "PostToolUse", - "PostToolUseFailure", - "PermissionRequest", - } - if agent == "claude" - else {"PreToolUse", "PostToolUse", "PermissionRequest"} - ) + } + assert expected_matchers == { + "PreToolUse", + "PostToolUse", + "PermissionRequest", + } | ({"PostToolUseFailure"} if agent == "claude" else set()) def test_render_relay_hooks_rejects_unsupported_agent(): diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index edfd9b0f5..cf3dae095 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -195,8 +195,12 @@ def test_prepare_claude_relay_writes_gateway_config_and_complete_hook_plugin( ) monkeypatch.setattr( adapter.relay_gateway, - "relay_cli_observability_version", - MagicMock(return_value=2), + "relay_cli_contract", + MagicMock( + return_value=adapter.relay_gateway.RelayCliContract( + version=(0, 6, 0), observability_version=2 + ) + ), ) relay = adapter.prepare_claude_relay(relay_payload) @@ -266,8 +270,12 @@ def test_build_options_adds_relay_plugin_and_gateway_environment( ) monkeypatch.setattr( adapter.relay_gateway, - "relay_cli_observability_version", - MagicMock(return_value=2), + "relay_cli_contract", + MagicMock( + return_value=adapter.relay_gateway.RelayCliContract( + version=(0, 6, 0), observability_version=2 + ) + ), ) relay = adapter.prepare_claude_relay(relay_payload) diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index cf2a7519c..cc68f0b46 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -192,6 +192,20 @@ def test_sdk_oneshot_uses_native_thread_and_turn_contract( ) +def test_sdk_can_use_an_explicit_codex_runtime(codex_payload, mock_codex, tmp_path): + codex_bin = tmp_path / "bin" / "codex" + codex_bin.parent.mkdir() + codex_bin.touch() + codex_payload["effective_config"]["config"]["harness"]["settings"][ + "codex_bin" + ] = str(codex_bin) + + output = adapter.run(codex_payload) + + assert output["completed"] is True + assert mock_codex.instances[0].config.codex_bin == str(codex_bin) + + def test_runtime_resumes_sdk_thread_across_invocations( codex_payload, mock_codex ): @@ -316,9 +330,9 @@ def test_relay_uses_gateway_and_request_scoped_sdk_config( assert client.config.env["NEMO_RELAY_GATEWAY_URL"] == gateway.url assert config["bypass_hook_trust"] is True assert config["features"]["hooks"] is True + assert config["features"]["multi_agent_v2"]["enabled"] is False assert config["features"]["web_search"] is False assert config["openai_base_url"] == gateway.url - assert "model_provider" not in config assert "model_providers" not in config assert config["hooks"]["SessionStart"][0]["hooks"][0] == { "type": "command", @@ -352,11 +366,15 @@ def test_prepare_relay_reuses_one_resolved_executable( config_path = tmp_path / "relay-config" / "config.toml" plugin_path = config_path.parent / "plugins.toml" resolve = MagicMock(return_value=executable) - version = MagicMock(return_value=2) + contract = MagicMock( + return_value=adapter.relay_gateway.RelayCliContract( + version=(0, 6, 0), observability_version=2 + ) + ) write = MagicMock(return_value=(config_path, plugin_path)) monkeypatch.setattr(adapter.relay_gateway, "resolve_relay_command", resolve) monkeypatch.setattr( - adapter.relay_gateway, "relay_cli_observability_version", version + adapter.relay_gateway, "relay_cli_contract", contract ) monkeypatch.setattr(adapter.relay_gateway, "find_available_tcp_port", lambda: 43210) monkeypatch.setattr( @@ -371,7 +389,7 @@ def test_prepare_relay_reuses_one_resolved_executable( assert relay is not None assert relay.gateway.executable == executable assert relay.gateway.url == "http://127.0.0.1:43210" - version.assert_called_once_with(executable) + contract.assert_called_once_with(executable) write.assert_called_once_with( relay_config={}, plugin_config={"version": 1, "components": []}, diff --git a/tests/e2e/test_codex.py b/tests/e2e/test_codex.py index 41e4b6c34..919650dfd 100644 --- a/tests/e2e/test_codex.py +++ b/tests/e2e/test_codex.py @@ -9,13 +9,55 @@ from __future__ import annotations import importlib.util +import json import os import shutil import uuid +from pathlib import Path import pytest +def _assert_semantic_relay_artifacts(output, expected_response: str) -> None: + artifacts = { + item["kind"]: Path(item["path"]) for item in output["relay_artifacts"] + } + events = [ + json.loads(line) + for line in artifacts["atof"].read_text(encoding="utf-8").splitlines() + ] + llm_starts = [ + event + for event in events + if event.get("category") == "llm" and event.get("scope_category") == "start" + ] + assert llm_starts, events + assert all( + isinstance(event.get("data", {}).get("content"), dict) + for event in llm_starts + ) + assert all(event["data"]["content"].get("model") for event in llm_starts) + + llm_ends = [ + event + for event in events + if event.get("category") == "llm" and event.get("scope_category") == "end" + ] + assert llm_ends, events + assert any( + (event.get("data", {}).get("usage") or {}).get("total_tokens", 0) > 0 + for event in llm_ends + ) + + trajectory = json.loads(artifacts["atif"].read_text(encoding="utf-8")) + agent_messages = [ + step.get("message", "") + for step in trajectory.get("steps", []) + if step.get("source") == "agent" + ] + assert any(expected_response.lower() in message.lower() for message in agent_messages) + + async def test_codex_sdk(): if os.environ.get("RUN_FABRIC_CODEX_INTEGRATION") != "1": pytest.skip("set RUN_FABRIC_CODEX_INTEGRATION=1 to run") @@ -99,6 +141,7 @@ async def _run_relay(relay_command: str) -> None: "atof", "atif", }, mapping + _assert_semantic_relay_artifacts(result["output"], "FABRIC_CODEX_RELAY_OK") nonce = f"fabric-relay-{uuid.uuid4().hex[:8]}" async with await client.start_runtime(config, base_dir=BASE_DIR) as runtime: From f6259cbb2476e3ddd865f4e613e7df55c1ebc894 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Thu, 16 Jul 2026 01:13:00 -0700 Subject: [PATCH 05/10] fix(claude): preserve SDK authentication parity Signed-off-by: Ajay Thorve --- ATTRIBUTIONS-Python.md | 2 +- adapters/claude/README.md | 13 +++++- adapters/claude/pyproject.toml | 2 +- .../nemo_fabric_adapters/claude/adapter.py | 8 ++++ adapters/claude/uv.lock | 16 ++++---- docs/integrations/claude.mdx | 9 ++++ tests/adapters/test_claude_adapter.py | 41 +++++++++++++++++++ uv.lock | 16 ++++---- 8 files changed, 87 insertions(+), 20 deletions(-) diff --git a/ATTRIBUTIONS-Python.md b/ATTRIBUTIONS-Python.md index 12dd4e04a..f0da289e8 100644 --- a/ATTRIBUTIONS-Python.md +++ b/ATTRIBUTIONS-Python.md @@ -1281,7 +1281,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -## claude-agent-sdk (0.2.114) +## claude-agent-sdk (0.2.120) ### Licenses License: `MIT` diff --git a/adapters/claude/README.md b/adapters/claude/README.md index b2439cad6..bbdef3f73 100644 --- a/adapters/claude/README.md +++ b/adapters/claude/README.md @@ -9,6 +9,10 @@ The `nvidia.fabric.claude` adapter uses the official Claude Agent SDK for Python behind Fabric's normalized invocation contract. The SDK is an implementation detail; consumers select the Claude harness by adapter ID. +This adapter pins `claude-agent-sdk==0.2.120`. The SDK supplies its compatible +Claude Code runtime unless `harness.settings.cli_path` explicitly selects +another executable. + ## Install ```bash @@ -40,8 +44,8 @@ Refer to the [Claude adapter authentication guide](https://nvidia-nemo-fabric.do for mode selection, required WIF variables, and the Relay boundary. Package installation is verified by the adapter wheel and module-entrypoint tests. -Relay-enabled runs also require the external `nemo-relay` CLI. Install the CLI -separately: +Relay-enabled runs also require the external `nemo-relay` CLI. Fabric accepts +CLI versions `>=0.6.0,<0.7.0`. Install the CLI separately: ```bash cargo install nemo-relay-cli @@ -217,3 +221,8 @@ RUN_FABRIC_CLAUDE_RELAY_INTEGRATION=1 uv run --no-sync pytest tests/e2e/test_cla ``` The first command uses the mock Claude client and does not require credentials. +Set `FABRIC_TEST_CLAUDE_MODEL` to override the default live-test model, +`claude-sonnet-4-5`. +The live Relay test applies the same semantic artifact contract as Codex: ATOF +must contain structured LLM requests and token usage, and ATIF must contain the +expected agent response. diff --git a/adapters/claude/pyproject.toml b/adapters/claude/pyproject.toml index 5ca2c65ef..f80d80986 100644 --- a/adapters/claude/pyproject.toml +++ b/adapters/claude/pyproject.toml @@ -26,7 +26,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "nemo-fabric-adapters-common == 0.1.0", - "claude-agent-sdk==0.2.114", + "claude-agent-sdk==0.2.120", "tomli-w~=1.2", ] diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index a49029bd5..c7de4192b 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -80,6 +80,7 @@ "TEMP", "TMP", "TMPDIR", + "USER", "USERPROFILE", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", @@ -741,6 +742,13 @@ async def run_claude(payload: dict[str, Any]) -> dict[str, Any]: messages.append(message) except (TimeoutError, ClaudeSDKError) as error: output = sdk_failure(error) + except Exception: + # Claude Agent SDK 0.2.120 can yield an error ResultMessage and then + # raise a plain Exception while closing the query stream. Preserve + # the typed terminal result, but do not hide unrelated exceptions. + if result is None or not result.is_error: + raise + output = normalize_result(payload, messages, result) else: if result is None: output = _failure( diff --git a/adapters/claude/uv.lock b/adapters/claude/uv.lock index 5f9e4b547..012c18e12 100644 --- a/adapters/claude/uv.lock +++ b/adapters/claude/uv.lock @@ -148,20 +148,20 @@ wheels = [ [[package]] name = "claude-agent-sdk" -version = "0.2.114" +version = "0.2.120" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "mcp" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/f3/a74b32b52f3c0013e317afe27b44946b9bbceb16b53c2a8d9614bfc0e7ad/claude_agent_sdk-0.2.114.tar.gz", hash = "sha256:53263a25bddd52b354ba2975b348cc978493bf29cb21a8c74a1b92b06f88faa3", size = 268633, upload-time = "2026-07-08T21:35:01.546Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/7f/7b69aed292a4edecae132e4dbe6b6decb4e88ec142fc91d117b19058c9e0/claude_agent_sdk-0.2.120.tar.gz", hash = "sha256:e428552f79a76e0d85789369eeb58249b33f350200124e5fc86b24168bd00805", size = 268639, upload-time = "2026-07-15T23:18:50.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/57/ded5a6e02940b73775e8235cc69c3f1f9aca15871b73951ea184f26b68b7/claude_agent_sdk-0.2.114-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5170a7cf8e5f30cdd59d60505b82e444abd655e8391e92e8390b1d539c100550", size = 69490216, upload-time = "2026-07-08T21:35:05.659Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ee/646435d91960e83cf6989d70adc6e9b47295a1e93bb1c2d7be366c8786c8/claude_agent_sdk-0.2.114-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:42b76e889d8219cf741789205adc9306b62524af205080362eb75b1bc09aa10b", size = 74389245, upload-time = "2026-07-08T21:35:10.367Z" }, - { url = "https://files.pythonhosted.org/packages/38/d0/f0c5ced2a1dcf28ad0da9643d81e535e9fa7c9cf70c09ae7573429e4bcdf/claude_agent_sdk-0.2.114-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:f583f04a8d7db22b5dc826c135225199e99c46092e563492aa2a13d52b81e8ed", size = 79425407, upload-time = "2026-07-08T21:35:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/1d/7a/2dc6f13a153187ec4ca0a2fd76e7e20cd3fb43d72eb8e01401fd4ab7a335/claude_agent_sdk-0.2.114-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:a3ec58cfff233c70782437a900fb152ef971b92887e03b03d218266285ad387a", size = 80450013, upload-time = "2026-07-08T21:35:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/c9/25/3cc0ea8ff94bdbc28cd0da69d4d6b4bd382b6f7d182b94351a313d59ceac/claude_agent_sdk-0.2.114-py3-none-win_amd64.whl", hash = "sha256:60096c88ead0ff7f05ae5cf90d7a75e47a02cbc16a97d0c813bc0409cbb0e334", size = 79985117, upload-time = "2026-07-08T21:35:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/5e8958704db0f8195e63f8ec4a80c5fb14756edc785bb3535e0dc5d91104/claude_agent_sdk-0.2.120-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ead9fb4bdaf70069978703ec6d74b30bd269e9632a4aea4dc8c4e999ac3a1b", size = 71110966, upload-time = "2026-07-15T23:18:54.743Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/c6cdad82ac100c8a45887999614e9fe206b77b43790dacc6de42b156ad4c/claude_agent_sdk-0.2.120-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:1248591c7bffeb6e10e8cd169e0766854951fba8816e3e7d81a003f8bfca6f08", size = 76067995, upload-time = "2026-07-15T23:18:58.398Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/248e3f58d0f0aa7d76bd34b11b18135cc124f5b9a9b55219cc1ca03d662a/claude_agent_sdk-0.2.120-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:abc73ccdf3decca566cd18084e74bc2f2d10b8b77cc1fd5ed4299d5c15e5078b", size = 81027434, upload-time = "2026-07-15T23:19:03.318Z" }, + { url = "https://files.pythonhosted.org/packages/11/59/6adb0c53534646f1d5ddc41226ff37b2adc413a472e2f87a9011548a137f/claude_agent_sdk-0.2.120-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:888070c246c92e102c52001d26532cd3646a700656d7c368f2f91a1d3c16b534", size = 82084704, upload-time = "2026-07-15T23:19:08.729Z" }, + { url = "https://files.pythonhosted.org/packages/2a/06/036b8dce1e86ecd5e2e1ddc281736cdb33bad6d24e748b9553e235b028fe/claude_agent_sdk-0.2.120-py3-none-win_amd64.whl", hash = "sha256:bc1441c94f60c9e7b4b8c641742fedf68451f573062ef69dd43d86a61b1fb219", size = 81958518, upload-time = "2026-07-15T23:19:13.082Z" }, ] [[package]] @@ -360,7 +360,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "claude-agent-sdk", specifier = "==0.2.114" }, + { name = "claude-agent-sdk", specifier = "==0.2.120" }, { name = "nemo-fabric-adapters-common", editable = "../common" }, { name = "tomli-w", specifier = "~=1.2" }, ] diff --git a/docs/integrations/claude.mdx b/docs/integrations/claude.mdx index 00f82bc61..de81e9ac7 100644 --- a/docs/integrations/claude.mdx +++ b/docs/integrations/claude.mdx @@ -12,6 +12,10 @@ Claude Code runtime. Fabric preserves Claude's native credential precedence and forwards only supported operating-system, configuration, and authentication variables plus values explicitly configured in `harness.settings.env`. +The adapter pins `claude-agent-sdk==0.2.120`. The SDK owns its compatible Claude +Code runtime unless `harness.settings.cli_path` explicitly selects another +executable. + ## Choose an Authentication Mode Use the mode that matches the execution environment: @@ -88,3 +92,8 @@ starts the gateway as a supervised sidecar and sets `ANTHROPIC_BASE_URL` for the Claude runtime. Claude still resolves its credential through the selected mode, and Fabric does not write authentication values to Relay configuration or artifacts. + +Fabric supports the external NeMo Relay CLI from `0.6.0` up to, but not +including, `0.7.0`. The Python package named `nemo-relay` does not install this +CLI. Fabric owns sidecar supervision and Claude configuration; Relay owns the +gateway transport and semantic observability pipeline. diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index 3f5241b0c..d5e2ab1e0 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -724,6 +724,36 @@ async def query_failure(*, prompt, options): assert not relay.plugin_path.exists() +async def test_run_claude_preserves_error_result_when_sdk_stream_raises( + claude_payload, + monkeypatch, +): + async def query_error_result(**_): + yield ResultMessage( + subtype="success", + duration_ms=10, + duration_api_ms=8, + is_error=True, + num_turns=1, + session_id="claude-session", + result="Not logged in", + ) + raise RuntimeError("raw SDK stream error") + + monkeypatch.setattr(adapter, "query", MagicMock(side_effect=query_error_result)) + + output = await adapter.run_claude(claude_payload) + + assert output["response"] == "Not logged in" + assert output["error"] == { + "code": "claude_result_failed", + "message": "Claude returned an error result", + "retryable": False, + "metadata": {"subtype": "success"}, + } + assert "raw SDK stream error" not in json.dumps(output) + + def test_run_reports_relay_start_failure_without_raw_diagnostic( relay_payload, monkeypatch, tmp_path ): @@ -820,6 +850,17 @@ def test_build_options_forwards_anthropic_auth_environment( assert options.env["FABRIC_UNRELATED_SECRET"] == "" +def test_build_options_preserves_unix_user_for_cached_login( + claude_payload, + monkeypatch, +): + monkeypatch.setenv("USER", "fabric-user") + + options = adapter.build_options(claude_payload, resume=None) + + assert options.env["USER"] == "fabric-user" + + @pytest.mark.parametrize( ("error", "code"), [ diff --git a/uv.lock b/uv.lock index 350dcd2f2..ac97192bb 100644 --- a/uv.lock +++ b/uv.lock @@ -432,20 +432,20 @@ wheels = [ [[package]] name = "claude-agent-sdk" -version = "0.2.114" +version = "0.2.120" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "mcp" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/f3/a74b32b52f3c0013e317afe27b44946b9bbceb16b53c2a8d9614bfc0e7ad/claude_agent_sdk-0.2.114.tar.gz", hash = "sha256:53263a25bddd52b354ba2975b348cc978493bf29cb21a8c74a1b92b06f88faa3", size = 268633, upload-time = "2026-07-08T21:35:01.546Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/7f/7b69aed292a4edecae132e4dbe6b6decb4e88ec142fc91d117b19058c9e0/claude_agent_sdk-0.2.120.tar.gz", hash = "sha256:e428552f79a76e0d85789369eeb58249b33f350200124e5fc86b24168bd00805", size = 268639, upload-time = "2026-07-15T23:18:50.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/57/ded5a6e02940b73775e8235cc69c3f1f9aca15871b73951ea184f26b68b7/claude_agent_sdk-0.2.114-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5170a7cf8e5f30cdd59d60505b82e444abd655e8391e92e8390b1d539c100550", size = 69490216, upload-time = "2026-07-08T21:35:05.659Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ee/646435d91960e83cf6989d70adc6e9b47295a1e93bb1c2d7be366c8786c8/claude_agent_sdk-0.2.114-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:42b76e889d8219cf741789205adc9306b62524af205080362eb75b1bc09aa10b", size = 74389245, upload-time = "2026-07-08T21:35:10.367Z" }, - { url = "https://files.pythonhosted.org/packages/38/d0/f0c5ced2a1dcf28ad0da9643d81e535e9fa7c9cf70c09ae7573429e4bcdf/claude_agent_sdk-0.2.114-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:f583f04a8d7db22b5dc826c135225199e99c46092e563492aa2a13d52b81e8ed", size = 79425407, upload-time = "2026-07-08T21:35:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/1d/7a/2dc6f13a153187ec4ca0a2fd76e7e20cd3fb43d72eb8e01401fd4ab7a335/claude_agent_sdk-0.2.114-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:a3ec58cfff233c70782437a900fb152ef971b92887e03b03d218266285ad387a", size = 80450013, upload-time = "2026-07-08T21:35:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/c9/25/3cc0ea8ff94bdbc28cd0da69d4d6b4bd382b6f7d182b94351a313d59ceac/claude_agent_sdk-0.2.114-py3-none-win_amd64.whl", hash = "sha256:60096c88ead0ff7f05ae5cf90d7a75e47a02cbc16a97d0c813bc0409cbb0e334", size = 79985117, upload-time = "2026-07-08T21:35:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/5e8958704db0f8195e63f8ec4a80c5fb14756edc785bb3535e0dc5d91104/claude_agent_sdk-0.2.120-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ead9fb4bdaf70069978703ec6d74b30bd269e9632a4aea4dc8c4e999ac3a1b", size = 71110966, upload-time = "2026-07-15T23:18:54.743Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/c6cdad82ac100c8a45887999614e9fe206b77b43790dacc6de42b156ad4c/claude_agent_sdk-0.2.120-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:1248591c7bffeb6e10e8cd169e0766854951fba8816e3e7d81a003f8bfca6f08", size = 76067995, upload-time = "2026-07-15T23:18:58.398Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/248e3f58d0f0aa7d76bd34b11b18135cc124f5b9a9b55219cc1ca03d662a/claude_agent_sdk-0.2.120-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:abc73ccdf3decca566cd18084e74bc2f2d10b8b77cc1fd5ed4299d5c15e5078b", size = 81027434, upload-time = "2026-07-15T23:19:03.318Z" }, + { url = "https://files.pythonhosted.org/packages/11/59/6adb0c53534646f1d5ddc41226ff37b2adc413a472e2f87a9011548a137f/claude_agent_sdk-0.2.120-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:888070c246c92e102c52001d26532cd3646a700656d7c368f2f91a1d3c16b534", size = 82084704, upload-time = "2026-07-15T23:19:08.729Z" }, + { url = "https://files.pythonhosted.org/packages/2a/06/036b8dce1e86ecd5e2e1ddc281736cdb33bad6d24e748b9553e235b028fe/claude_agent_sdk-0.2.120-py3-none-win_amd64.whl", hash = "sha256:bc1441c94f60c9e7b4b8c641742fedf68451f573062ef69dd43d86a61b1fb219", size = 81958518, upload-time = "2026-07-15T23:19:13.082Z" }, ] [[package]] @@ -2140,7 +2140,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "claude-agent-sdk", specifier = "==0.2.114" }, + { name = "claude-agent-sdk", specifier = "==0.2.120" }, { name = "nemo-fabric-adapters-common", editable = "adapters/common" }, { name = "tomli-w", specifier = "~=1.2" }, ] From fdec4538ac879202dfd28a7f682e077d02a384a0 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Thu, 16 Jul 2026 01:13:06 -0700 Subject: [PATCH 06/10] test(relay): enforce semantic SDK trace parity Signed-off-by: Ajay Thorve --- adapters/codex/README.md | 28 ++++++++++++- docs/integrations/codex.mdx | 60 ++++++++++++++++++++++++++++ examples/code_review_agent/README.md | 5 ++- tests/_utils/utils.py | 50 +++++++++++++++++++++++ tests/e2e/test_claude.py | 15 ++++++- tests/e2e/test_codex.py | 52 +++++------------------- 6 files changed, 162 insertions(+), 48 deletions(-) diff --git a/adapters/codex/README.md b/adapters/codex/README.md index 63261373c..6d2da99f1 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -48,6 +48,13 @@ The dependency graph includes `openai-codex-cli-bin`. The Codex SDK owns this pinned app-server distribution; Fabric does not treat it as a user-installed command or an adapter descriptor requirement. +This adapter pins `openai-codex==0.1.0b3`, which pins +`openai-codex-cli-bin==0.137.0a4`. A newer `codex` command on `PATH` is not used +implicitly. When testing a newer compatible runtime, set +`harness.settings.codex_bin` to an app-server path that is absolute or relative +to the Fabric config root. Fabric passes the resolved path through +`CodexConfig.codex_bin`; the SDK remains the execution driver. + ## Execution Model Each Fabric invocation starts a fresh SDK client and closes its app-server @@ -76,6 +83,7 @@ Codex-specific controls belong in `harness.settings`: - `base_instructions` and `developer_instructions` - `personality`, `reasoning_effort`, `service_name`, and `service_tier` - `output_schema` for SDK-native structured output +- `codex_bin` for an explicit Codex app-server runtime override - `config_overrides` as dotted request-scoped Codex configuration keys - `timeout_seconds`, defaulting to 1800 - `env` for variables explicitly forwarded to the Codex runtime @@ -115,7 +123,7 @@ back to CLI execution. Relay routes and observes requests; it does not provide OpenAI credentials or change the selected Codex authentication mode. Relay-enabled runs require the external `nemo-relay` CLI in addition to the -Python package dependencies: +Python package dependencies. Fabric accepts CLI versions `>=0.6.0,<0.7.0`: ```bash cargo install nemo-relay-cli @@ -125,6 +133,18 @@ The `nemo-relay` Python package does not install this executable. Refer to the [NeMo Relay installation guide](https://docs.nvidia.com/nemo/relay/getting-started/installation) for other supported installation methods. +Relay owns HTTP content decoding at the gateway boundary; Fabric does not +configure Codex request compression. The initial Relay `0.6.0` source tag +cannot recover semantic fields from zstd-compressed SDK requests. Until a later +`0.6.x` release contains the fix, use a build that includes +[NeMo Relay PR #452](https://github.com/NVIDIA/NeMo-Relay/pull/452). + +For Phoenix, native Codex OpenTelemetry targets the OTLP collector at +`http://localhost:4318/v1/traces` and provides low-level app-server spans. +Relay OpenInference provides the semantic chain, LLM, and tool hierarchy with +decoded prompt, response, and token attributes. Prefer Relay OpenInference for +agent-turn inspection. + ## Local Validation Run the unit and opt-in real SDK tests separately: @@ -137,8 +157,12 @@ RUN_FABRIC_CODEX_RELAY_INTEGRATION=1 \ uv run pytest tests/e2e/test_codex.py -q ``` +Set `FABRIC_TEST_CODEX_BIN=/path/to/codex` on either opt-in command to validate +an explicit app-server override instead of the SDK-pinned runtime. + The SDK test uses the current Codex authentication state and exercises both a one-shot invocation and multi-turn thread resume. The Relay test additionally requires an external gateway binary and verifies one-shot and resumed model responses, stable thread identity, ATOF, and ATIF; gateway startup alone is not -a passing result. +a passing result. The semantic regression also requires decoded LLM request +content, a model, token usage, and the expected agent response in ATIF. diff --git a/docs/integrations/codex.mdx b/docs/integrations/codex.mdx index 052b9ee86..4401695c4 100644 --- a/docs/integrations/codex.mdx +++ b/docs/integrations/codex.mdx @@ -11,6 +11,23 @@ The `nvidia.fabric.codex` adapter uses the Codex Python SDK and its pinned app-server runtime. Fabric does not execute `codex` for agent turns or fall back to the legacy Codex CLI adapter. +## Select a Codex Runtime + +The adapter pins `openai-codex==0.1.0b3`. That SDK release installs +`openai-codex-cli-bin==0.137.0a4` as its app-server runtime. A newer `codex` +command on `PATH` does not replace the SDK-owned runtime. + +To test a newer compatible app-server explicitly, set an absolute path in +`harness.settings.codex_bin`: + +```python +config.harness.settings["codex_bin"] = "/path/to/codex" +``` + +Fabric passes this path to `CodexConfig.codex_bin`; it does not invoke the +command as a CLI adapter. Pin the override in reproducible environments because +the app-server protocol can change before a matching Python SDK is published. + ## Choose an Authentication Mode Codex supports the following OpenAI authentication modes for local work: @@ -63,3 +80,46 @@ from the selected Codex authentication mode. Fabric supplies Relay configuration to the SDK for only the current request. It does not copy the Codex credential store into Relay configuration or persist credentials in Relay artifacts. + +Fabric supports the external NeMo Relay CLI from `0.6.0` up to, but not +including, `0.7.0`. The Python package named `nemo-relay` is a separate library +dependency and does not install the CLI. Fabric owns sidecar supervision and +request-scoped SDK configuration. Relay owns gateway transport behavior, +including decoding `Content-Encoding` before it constructs managed LLM events. +There is no Fabric compression setting. + +The initial NeMo Relay `0.6.0` source tag cannot recover semantic fields from +zstd-compressed Codex SDK requests. Until a later `0.6.x` release contains the +fix, use a build that includes +[NeMo Relay PR #452](https://github.com/NVIDIA/NeMo-Relay/pull/452). Fabric's +opt-in Relay E2E rejects opaque request bodies and missing token usage. + +## Compare Phoenix Trace Modes + +Use native Codex OpenTelemetry when you need low-level app-server diagnostics. +For Phoenix, send native OTLP/HTTP traces to the collector port, not the Phoenix +web port: + +```python +from examples.code_review_agent import codex_config, with_native_otel + +config = with_native_otel(codex_config()) +``` + +The native configuration uses `http://localhost:4318/v1/traces`. Native Codex +spans expose internal operations and can be high-volume; they do not provide an +OpenInference conversation hierarchy or consistently populated prompt and +response fields. + +Use Relay OpenInference for semantic Phoenix inspection: + +```python +from examples.code_review_agent import codex_config, with_relay_openinference + +config = with_relay_openinference(codex_config()) +``` + +Relay produces a chain root with LLM and tool children, decoded request and +response values, and token usage. This is the recommended mode for reviewing an +agent turn in Phoenix. Use native Codex OpenTelemetry only when the raw +app-server trace is the required diagnostic surface. diff --git a/examples/code_review_agent/README.md b/examples/code_review_agent/README.md index 19ec6793e..d6efeb8a6 100644 --- a/examples/code_review_agent/README.md +++ b/examples/code_review_agent/README.md @@ -65,8 +65,9 @@ The entrypoint exposes complete harness configs defined in Add `--relay` to any variant to enable the Relay ATOF and ATIF configuration: -Relay runs require the optional NeMo Relay dependency in the selected adapter -environment. +Relay requirements depend on the selected adapter. The Codex and Claude SDK +adapters require an external `nemo-relay` CLI in the supported `0.6.x` range; +the Python package named `nemo-relay` does not install that command. ```bash .venv/bin/python -m examples.code_review_agent \ diff --git a/tests/_utils/utils.py b/tests/_utils/utils.py index 6917118bb..d71b9edf5 100644 --- a/tests/_utils/utils.py +++ b/tests/_utils/utils.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import json import subprocess from pathlib import Path @@ -10,6 +11,55 @@ FABRIC_COMMAND = ("cargo", "run", "-q", "-p", "nemo-fabric-cli", "--") +def _relay_event_total_tokens(event: dict) -> int: + profile = event.get("category_profile") or {} + annotated = profile.get("annotated_response") or {} + data = event.get("data") or {} + usage = annotated.get("usage") or data.get("usage") or {} + return usage.get("total_tokens", 0) + + +def assert_semantic_relay_artifacts(output, expected_response: str) -> None: + """Assert Relay artifacts contain model, usage, and agent-response semantics.""" + + artifacts = { + item["kind"]: Path(item["path"]) for item in output["relay_artifacts"] + } + events = [ + json.loads(line) + for line in artifacts["atof"].read_text(encoding="utf-8").splitlines() + ] + llm_starts = [ + event + for event in events + if event.get("category") == "llm" and event.get("scope_category") == "start" + ] + assert llm_starts, events + assert all( + isinstance(event.get("data", {}).get("content"), dict) + for event in llm_starts + ) + assert all(event["data"]["content"].get("model") for event in llm_starts) + + llm_ends = [ + event + for event in events + if event.get("category") == "llm" and event.get("scope_category") == "end" + ] + assert llm_ends, events + assert any(_relay_event_total_tokens(event) > 0 for event in llm_ends) + + trajectory = json.loads(artifacts["atif"].read_text(encoding="utf-8")) + agent_messages = [ + message + for step in trajectory.get("steps", []) + if isinstance(step, dict) + if step.get("source") == "agent" + if isinstance(message := step.get("message"), str) + ] + assert any(expected_response.lower() in message.lower() for message in agent_messages) + + def run_fabric_cli( *args: object, stdin: str | None = None, diff --git a/tests/e2e/test_claude.py b/tests/e2e/test_claude.py index d007b8953..ee5e5eca8 100644 --- a/tests/e2e/test_claude.py +++ b/tests/e2e/test_claude.py @@ -11,6 +11,7 @@ from pathlib import Path import pytest +from _utils.utils import assert_semantic_relay_artifacts from nemo_fabric import ( EnvironmentConfig, Fabric, @@ -93,7 +94,15 @@ def fabric_config( resolution="preinstalled", settings=settings, ), - models={"default": ModelConfig(provider="anthropic", model="claude-test-model")}, + models={ + "default": ModelConfig( + provider="anthropic", + model=os.environ.get( + "FABRIC_TEST_CLAUDE_MODEL", + "claude-sonnet-4-5", + ), + ) + }, runtime=RuntimeConfig(artifacts=tmp_path / "artifacts"), environment=EnvironmentConfig( provider="local", @@ -254,3 +263,7 @@ async def test_live_claude_relay_one_shot(tmp_path): "atof", "atif", } + assert_semantic_relay_artifacts( + result.output, + "FABRIC_CLAUDE_RELAY_OK", + ) diff --git a/tests/e2e/test_codex.py b/tests/e2e/test_codex.py index 919650dfd..8c4da773f 100644 --- a/tests/e2e/test_codex.py +++ b/tests/e2e/test_codex.py @@ -9,53 +9,19 @@ from __future__ import annotations import importlib.util -import json import os import shutil import uuid -from pathlib import Path import pytest +from _utils.utils import assert_semantic_relay_artifacts -def _assert_semantic_relay_artifacts(output, expected_response: str) -> None: - artifacts = { - item["kind"]: Path(item["path"]) for item in output["relay_artifacts"] - } - events = [ - json.loads(line) - for line in artifacts["atof"].read_text(encoding="utf-8").splitlines() - ] - llm_starts = [ - event - for event in events - if event.get("category") == "llm" and event.get("scope_category") == "start" - ] - assert llm_starts, events - assert all( - isinstance(event.get("data", {}).get("content"), dict) - for event in llm_starts - ) - assert all(event["data"]["content"].get("model") for event in llm_starts) - - llm_ends = [ - event - for event in events - if event.get("category") == "llm" and event.get("scope_category") == "end" - ] - assert llm_ends, events - assert any( - (event.get("data", {}).get("usage") or {}).get("total_tokens", 0) > 0 - for event in llm_ends - ) - - trajectory = json.loads(artifacts["atif"].read_text(encoding="utf-8")) - agent_messages = [ - step.get("message", "") - for step in trajectory.get("steps", []) - if step.get("source") == "agent" - ] - assert any(expected_response.lower() in message.lower() for message in agent_messages) +def _select_codex_runtime(config): + codex_bin = os.environ.get("FABRIC_TEST_CODEX_BIN") + if codex_bin: + config.harness.settings["codex_bin"] = codex_bin + return config async def test_codex_sdk(): @@ -87,7 +53,7 @@ async def _run() -> None: from examples.code_review_agent import BASE_DIR, codex_config from nemo_fabric import Fabric - config = codex_config() + config = _select_codex_runtime(codex_config()) nonce = f"fabric-{uuid.uuid4().hex[:8]}" client = Fabric() oneshot = await client.run( @@ -123,7 +89,7 @@ async def _run_relay(relay_command: str) -> None: from examples.code_review_agent import BASE_DIR, codex_config, with_relay from nemo_fabric import Fabric - config = with_relay(codex_config()) + config = _select_codex_runtime(with_relay(codex_config())) config.harness.settings["nemo_relay_command"] = relay_command client = Fabric() result = await client.run( @@ -141,7 +107,7 @@ async def _run_relay(relay_command: str) -> None: "atof", "atif", }, mapping - _assert_semantic_relay_artifacts(result["output"], "FABRIC_CODEX_RELAY_OK") + assert_semantic_relay_artifacts(result["output"], "FABRIC_CODEX_RELAY_OK") nonce = f"fabric-relay-{uuid.uuid4().hex[:8]}" async with await client.start_runtime(config, base_dir=BASE_DIR) as runtime: From 3addf389da6edfe49b77b04e8713d6ffe36974ca Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Thu, 16 Jul 2026 01:14:35 -0700 Subject: [PATCH 07/10] test(claude): align environment isolation Signed-off-by: Ajay Thorve --- tests/adapters/test_claude_adapter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index d5e2ab1e0..c48d9062d 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -852,9 +852,8 @@ def test_build_options_forwards_anthropic_auth_environment( def test_build_options_preserves_unix_user_for_cached_login( claude_payload, - monkeypatch, ): - monkeypatch.setenv("USER", "fabric-user") + os.environ["USER"] = "fabric-user" options = adapter.build_options(claude_payload, resume=None) From 2af259ddce39574d53b1d71bfb048085b5a0676a Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Thu, 16 Jul 2026 07:52:28 -0700 Subject: [PATCH 08/10] fix(adapters): address review feedback Signed-off-by: Ajay Thorve --- .../nemo_fabric_adapters/claude/adapter.py | 14 ++++- adapters/codex/README.md | 17 ++++-- .../src/nemo_fabric_adapters/codex/adapter.py | 6 +- .../common/relay_hooks.py | 2 +- .../deepagents/adapter.py | 61 +++++++++++-------- tests/_utils/utils.py | 10 ++- tests/adapters/test_claude_adapter.py | 16 +++-- tests/adapters/test_codex_adapter.py | 29 +++++++++ tests/adapters/test_deepagents.py | 21 +++++++ 9 files changed, 134 insertions(+), 42 deletions(-) diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index c7de4192b..9e3df040f 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -7,6 +7,7 @@ import asyncio import json +import logging import math import os import shlex @@ -32,6 +33,8 @@ from nemo_fabric_adapters.common import relay_hooks from nemo_fabric_adapters.common import utils as common_utils +LOGGER = logging.getLogger(__name__) + PERMISSION_MODES = { "default", "acceptEdits", @@ -593,9 +596,15 @@ def normalize_message(message: Message) -> dict[str, Any]: return {"type": type(message).__name__, "message": _json_safe(message)} +def _result_failed(result: ResultMessage) -> bool: + return bool(result.is_error) or ( + isinstance(result.subtype, str) and result.subtype.startswith("error_") + ) + + def normalize_result(payload: dict[str, Any], messages: list[Message], result: ResultMessage) -> dict[str, Any]: del payload - failed = bool(result.is_error) or (isinstance(result.subtype, str) and result.subtype.startswith("error_")) + failed = _result_failed(result) error = None if failed: error = { @@ -746,8 +755,9 @@ async def run_claude(payload: dict[str, Any]) -> dict[str, Any]: # Claude Agent SDK 0.2.120 can yield an error ResultMessage and then # raise a plain Exception while closing the query stream. Preserve # the typed terminal result, but do not hide unrelated exceptions. - if result is None or not result.is_error: + if result is None or not _result_failed(result): raise + LOGGER.exception("Claude SDK stream raised after a failed terminal result") output = normalize_result(payload, messages, result) else: if result is None: diff --git a/adapters/codex/README.md b/adapters/codex/README.md index 6d2da99f1..d5ed8b07b 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -123,10 +123,14 @@ back to CLI execution. Relay routes and observes requests; it does not provide OpenAI credentials or change the selected Codex authentication mode. Relay-enabled runs require the external `nemo-relay` CLI in addition to the -Python package dependencies. Fabric accepts CLI versions `>=0.6.0,<0.7.0`: +Python package dependencies. Fabric accepts CLI versions `>=0.6.0,<0.7.0`. +Until the request-decoding fix is released, install the tested PR revision: ```bash -cargo install nemo-relay-cli +git clone https://github.com/NVIDIA/NeMo-Relay.git nemo-relay +git -C nemo-relay fetch origin pull/452/head +git -C nemo-relay checkout --detach 0b02e01ac10d7d678da28830feba0ebf6743a7c0 +cargo install --locked --path nemo-relay/crates/cli ``` The `nemo-relay` Python package does not install this executable. Refer to the @@ -134,10 +138,11 @@ The `nemo-relay` Python package does not install this executable. Refer to the for other supported installation methods. Relay owns HTTP content decoding at the gateway boundary; Fabric does not -configure Codex request compression. The initial Relay `0.6.0` source tag -cannot recover semantic fields from zstd-compressed SDK requests. Until a later -`0.6.x` release contains the fix, use a build that includes -[NeMo Relay PR #452](https://github.com/NVIDIA/NeMo-Relay/pull/452). +configure Codex request compression. The Relay `0.6.0-alpha.20260716` tag cannot +recover semantic fields from zstd-compressed SDK requests. Until a later `0.6.x` +release contains the fix, use the pinned +[NeMo Relay PR #452](https://github.com/NVIDIA/NeMo-Relay/pull/452) revision +above. For Phoenix, native Codex OpenTelemetry targets the OTLP collector at `http://localhost:4318/v1/traces` and provides low-level app-server spans. diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py index b1af468e2..17959350c 100644 --- a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -557,10 +557,10 @@ def sdk_config( ) -> CodexConfig: codex_bin = _optional_string(_settings(payload), "codex_bin") if codex_bin is not None: - path = Path(codex_bin).expanduser() + path = Path(codex_bin) if not path.is_absolute(): - path = Path(common_utils.config_root(payload)) / path - codex_bin = str(path.resolve()) + path = (Path(common_utils.config_root(payload)) / path).resolve() + codex_bin = str(path) return CodexConfig( codex_bin=codex_bin, cwd=str(resolve_cwd(payload)), diff --git a/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py b/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py index 56b8d2378..110bfcfec 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py +++ b/adapters/common/src/nemo_fabric_adapters/common/relay_hooks.py @@ -42,7 +42,7 @@ "PostCompact", ), } -RELAY_TOOL_HOOK_EVENTS = { +RELAY_TOOL_HOOK_EVENTS: dict[RelayHookAgent, frozenset[str]] = { "claude": frozenset( { "PreToolUse", diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index 9e5cf4665..fcbe78966 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -18,10 +18,13 @@ import os import shlex import uuid +from collections.abc import Callable from pathlib import Path from typing import Any from typing import NamedTuple +from langchain.agents.middleware import AgentMiddleware +from langchain_core.messages import ToolMessage import nemo_fabric_adapters.common.utils as common_utils HARNESS = "deepagents" @@ -55,6 +58,36 @@ class AdapterConfigError(RuntimeError): """Raised for invalid Deep Agents adapter configuration (normalized to a failure).""" +class ToolGateMiddleware(AgentMiddleware): # type: ignore[misc] + """Block tool calls selected by an adapter-owned policy.""" + + def __init__( + self, + is_blocked: Callable[[Any], bool], + message: Callable[[Any], str], + ): + self._is_blocked = is_blocked + self._message = message + + def _blocked(self, request: Any) -> ToolMessage: + name = request.tool_call.get("name") + return ToolMessage( + content=self._message(name), + tool_call_id=request.tool_call.get("id", ""), + status="error", + ) + + async def awrap_tool_call(self, request: Any, handler: Any) -> Any: + if self._is_blocked(request.tool_call.get("name")): + return self._blocked(request) + return await handler(request) + + def wrap_tool_call(self, request: Any, handler: Any) -> Any: + if self._is_blocked(request.tool_call.get("name")): + return self._blocked(request) + return handler(request) + + def resolve_api_key_env(settings: dict[str, Any], model_config: dict[str, Any]) -> str: """Resolve the credential env var, defaulting per provider. @@ -211,30 +244,10 @@ def _blocked_tool_names(payload: dict[str, Any]) -> set[str]: return set(common_utils.blocked_tools(payload)) -def _tool_gate_middleware(is_blocked: Any, message: Any) -> Any: - from langchain.agents.middleware import AgentMiddleware - from langchain_core.messages import ToolMessage - - def _blocked(request: Any) -> Any: - name = request.tool_call.get("name") - return ToolMessage( - content=message(name), - tool_call_id=request.tool_call.get("id", ""), - status="error", - ) - - class ToolGateMiddleware(AgentMiddleware): # type: ignore[misc] - async def awrap_tool_call(self, request: Any, handler: Any) -> Any: - if is_blocked(request.tool_call.get("name")): - return _blocked(request) - return await handler(request) - - def wrap_tool_call(self, request: Any, handler: Any) -> Any: - if is_blocked(request.tool_call.get("name")): - return _blocked(request) - return handler(request) - - return ToolGateMiddleware() +def _tool_gate_middleware( + is_blocked: Callable[[Any], bool], message: Callable[[Any], str] +) -> ToolGateMiddleware: + return ToolGateMiddleware(is_blocked, message) def blocked_tools_middleware(blocked: set[str]) -> Any: diff --git a/tests/_utils/utils.py b/tests/_utils/utils.py index d71b9edf5..00c565beb 100644 --- a/tests/_utils/utils.py +++ b/tests/_utils/utils.py @@ -3,7 +3,9 @@ import json import subprocess +from collections.abc import Mapping from pathlib import Path +from typing import Any import yaml @@ -19,7 +21,9 @@ def _relay_event_total_tokens(event: dict) -> int: return usage.get("total_tokens", 0) -def assert_semantic_relay_artifacts(output, expected_response: str) -> None: +def assert_semantic_relay_artifacts( + output: Mapping[str, Any], expected_response: str +) -> None: """Assert Relay artifacts contain model, usage, and agent-response semantics.""" artifacts = { @@ -57,7 +61,9 @@ def assert_semantic_relay_artifacts(output, expected_response: str) -> None: if step.get("source") == "agent" if isinstance(message := step.get("message"), str) ] - assert any(expected_response.lower() in message.lower() for message in agent_messages) + assert any( + expected_response.lower() in message.lower() for message in agent_messages + ), agent_messages def run_fabric_cli( diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index c48d9062d..be62d3bd5 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -724,16 +724,23 @@ async def query_failure(*, prompt, options): assert not relay.plugin_path.exists() -async def test_run_claude_preserves_error_result_when_sdk_stream_raises( +@pytest.mark.parametrize( + ("subtype", "is_error"), + [("success", True), ("error_max_budget_usd", False)], +) +async def test_run_claude_preserves_failed_result_when_sdk_stream_raises( claude_payload, monkeypatch, + caplog, + subtype, + is_error, ): async def query_error_result(**_): yield ResultMessage( - subtype="success", + subtype=subtype, duration_ms=10, duration_api_ms=8, - is_error=True, + is_error=is_error, num_turns=1, session_id="claude-session", result="Not logged in", @@ -749,9 +756,10 @@ async def query_error_result(**_): "code": "claude_result_failed", "message": "Claude returned an error result", "retryable": False, - "metadata": {"subtype": "success"}, + "metadata": {"subtype": subtype}, } assert "raw SDK stream error" not in json.dumps(output) + assert "raw SDK stream error" in caplog.text def test_run_reports_relay_start_failure_without_raw_diagnostic( diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index cc68f0b46..991605de0 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -206,6 +206,31 @@ def test_sdk_can_use_an_explicit_codex_runtime(codex_payload, mock_codex, tmp_pa assert mock_codex.instances[0].config.codex_bin == str(codex_bin) +@pytest.mark.parametrize("codex_bin", ["bin/codex", "~/bin/codex"]) +def test_sdk_resolves_relative_codex_runtime_from_config_root( + codex_payload, codex_bin +): + codex_payload["effective_config"]["config"]["harness"]["settings"][ + "codex_bin" + ] = codex_bin + + config = adapter.sdk_config(codex_payload, relay=None) + + config_root = Path(codex_payload["effective_config"]["config_root"]) + assert config.codex_bin == str((config_root / codex_bin).resolve()) + + +def test_sdk_keeps_absolute_codex_runtime_path(codex_payload, tmp_path): + codex_bin = tmp_path / "bin" / ".." / "codex" + codex_payload["effective_config"]["config"]["harness"]["settings"][ + "codex_bin" + ] = str(codex_bin) + + config = adapter.sdk_config(codex_payload, relay=None) + + assert config.codex_bin == str(codex_bin) + + def test_runtime_resumes_sdk_thread_across_invocations( codex_payload, mock_codex ): @@ -389,6 +414,10 @@ def test_prepare_relay_reuses_one_resolved_executable( assert relay is not None assert relay.gateway.executable == executable assert relay.gateway.url == "http://127.0.0.1:43210" + resolve.assert_called_once_with( + Path(codex_payload["effective_config"]["config_root"]).resolve(), + "nemo-relay", + ) contract.assert_called_once_with(executable) write.assert_called_once_with( relay_config={}, diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index 86d486965..40b904828 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -603,6 +603,7 @@ def request(name: str) -> types.SimpleNamespace: gates = [create_kwargs["middleware"][-1]] gates.extend(subagent["middleware"][-1] for subagent in subagents) + assert all(type(gate) is adapter.ToolGateMiddleware for gate in gates) for middleware in gates: blocked = await middleware.awrap_tool_call(request("write_file"), handler) assert isinstance(blocked, ToolMessage) @@ -635,6 +636,26 @@ async def test_blocked_tools_reject_unenforceable_subagents(tmp_path, make_paylo await adapter.build_agent_kwargs(payload, MagicMock(), settings) +@pytest.mark.parametrize( + ("subagents", "message"), + [ + ( + {"name": "researcher"}, + "harness.settings.deepagents.subagents must be a list when tools.blocked is configured.", + ), + ( + [{"name": "researcher"}, "invalid"], + "Deep Agents subagents must be mappings when tools.blocked is configured.", + ), + ], +) +def test_gated_subagents_reject_invalid_configuration(subagents, message): + with pytest.raises(adapter.AdapterConfigError) as error: + adapter._gated_subagents(subagents, {"write_file"}) + + assert str(error.value) == message + + async def test_deepagents_passthrough_forwards_supported_options(tmp_path, make_payload, fake_sdks): # Documented JSON-serializable options reach create_deep_agent unchanged. payload = make_payload(tmp_path) From 78e97d6677e3affa5e7740b97ba03c93c2d6bca1 Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Thu, 16 Jul 2026 12:21:25 -0700 Subject: [PATCH 09/10] docs: track temporary Relay source install Signed-off-by: Ajay Thorve --- TODO.md | 29 +++++++++++++++++++++++++++++ adapters/codex/README.md | 3 +++ 2 files changed, 32 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 000000000..46560ad98 --- /dev/null +++ b/TODO.md @@ -0,0 +1,29 @@ + + +# Temporary Workarounds + +Track repository workarounds that must be removed after an upstream dependency +ships. Each entry must identify an owner, upstream reference, removal condition, +and cleanup validation. + +## NeMo Relay 0.6.x Request Decoding Release + +- **Status:** Waiting for an upstream release +- **Owner:** `@AjayThorve` +- **Added:** July 16, 2026 +- **Affected documentation:** `adapters/codex/README.md` and + `docs/integrations/codex.mdx` +- **Reason:** Released NeMo Relay versions do not yet decode the + `zstd`-compressed request bodies emitted by the Codex SDK, so semantic Relay + artifacts require a source installation. +- **Upstream resolution:** + [NVIDIA/NeMo-Relay#452](https://github.com/NVIDIA/NeMo-Relay/pull/452), merged + as `fe144d0d23e483c8216537118304e306abc20837` +- **Removal condition:** A published `nemo-relay-cli` version in Fabric's + supported `>=0.6.0,<0.7.0` range contains the merged request-decoding fix. +- **Cleanup:** Replace the pinned source-install instructions with the released + CLI installation, run the Codex Relay end-to-end test, update both affected + documentation files, and remove this entry. diff --git a/adapters/codex/README.md b/adapters/codex/README.md index d5ed8b07b..990dc8878 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -133,6 +133,9 @@ git -C nemo-relay checkout --detach 0b02e01ac10d7d678da28830feba0ebf6743a7c0 cargo install --locked --path nemo-relay/crates/cli ``` +Removal of this temporary source installation is tracked in +[TODO.md](../../TODO.md#nemo-relay-06x-request-decoding-release). + The `nemo-relay` Python package does not install this executable. Refer to the [NeMo Relay installation guide](https://docs.nvidia.com/nemo/relay/getting-started/installation) for other supported installation methods. From 26e2ebaa82e7d284c71dac983286b14cac209c2f Mon Sep 17 00:00:00 2001 From: Ajay Thorve Date: Thu, 16 Jul 2026 12:23:53 -0700 Subject: [PATCH 10/10] docs: simplify workaround tracking Signed-off-by: Ajay Thorve --- TODO.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 46560ad98..8f3433689 100644 --- a/TODO.md +++ b/TODO.md @@ -6,13 +6,12 @@ SPDX-License-Identifier: Apache-2.0 # Temporary Workarounds Track repository workarounds that must be removed after an upstream dependency -ships. Each entry must identify an owner, upstream reference, removal condition, -and cleanup validation. +ships. Each entry must identify an upstream reference, removal condition, and +cleanup validation. ## NeMo Relay 0.6.x Request Decoding Release - **Status:** Waiting for an upstream release -- **Owner:** `@AjayThorve` - **Added:** July 16, 2026 - **Affected documentation:** `adapters/codex/README.md` and `docs/integrations/codex.mdx`