diff --git a/docs/guideline.html b/docs/guideline.html index 8712012d..3c8f6705 100644 --- a/docs/guideline.html +++ b/docs/guideline.html @@ -352,7 +352,7 @@

1.3 Key Features #

Validation gating

Every candidate skill is scored on a held-out selection split and only accepted if it beats the current/best skill.

Slow update

Epoch-boundary longitudinal comparison writes guidance into a protected region — momentum against forgetting. Force-injected or selection-gated.

Meta skill

Optimizer-side memory that reflects on what worked across epochs and feeds back into reflection.

-

Pluggable backends

OpenAI / Azure OpenAI, Anthropic Claude, local Qwen (vLLM), plus Codex/Claude-Code exec backends for the target.

+

Pluggable backends

OpenAI / Azure OpenAI, Anthropic Claude, local Qwen (vLLM), plus Codex exec for optimizer and target, and Claude-Code exec for the target.

Six benchmarks

SearchQA, DocVQA, ALFWorld, LiveMathematicianBench, SpreadsheetBench, OfficeQA — each a self-contained env module.

Auto-resume

Every run is checkpointed step-by-step; re-running the same command continues from the last completed step.

@@ -727,7 +727,7 @@

6.2 model.* #

backendstrazure_openaiHigh-level backend label for the run. optimizerstrgpt-5.5Optimizer model deployment (writes skill edits). targetstrgpt-5.5Target model deployment (executes tasks). - optimizer_backendstropenai_chatClient path for the optimizer: openai_chat or claude_chat. + optimizer_backendstropenai_chatClient path for the optimizer: openai_chat / claude_chat / qwen_chat / minimax_chat / codex_exec. target_backendstropenai_chatClient path for the target: openai_chat / claude_chat / qwen_chat / codex_exec / claude_code_exec. reasoning_effortstrmediumlow / medium / high / xhigh / max (or empty). rewrite_reasoning_effortstr""Override effort for full-rewrite calls (empty = inherit). @@ -738,7 +738,7 @@

6.2 model.* #

Separate optimizer / target endpoints -

Every azure_openai_* key also has optimizer_azure_openai_* and target_azure_openai_* variants, letting you point the optimizer and target at different Azure resources. Exec backends (codex_exec, claude_code_exec) add their own codex_exec_* / claude_code_exec_* knobs (sandbox, reasoning effort, SDK mode, etc.).

+

Every azure_openai_* key also has optimizer_azure_openai_* and target_azure_openai_* variants, letting you point the optimizer and target at different Azure resources. Exec backends add their own knobs, including codex_exec_* for optimizer/target Codex runs and claude_code_exec_* for target Claude-Code runs (sandbox, reasoning effort, SDK mode, etc.).

diff --git a/docs/reference/api.md b/docs/reference/api.md index 8e364c7a..509fd768 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -187,7 +187,7 @@ not via a base class subclass. Supported values (as of this writing): | `claude_chat` | ✓ | ✓ | | `qwen_chat` | ✓ | ✓ | | `minimax_chat` | ✓ | ✓ | -| `codex_exec` | — | ✓ | +| `codex_exec` | ✓ | ✓ | | `claude_code_exec` | — | ✓ | See `skillopt/model/backend_config.py` for the live whitelist and diff --git a/docs/reference/config.md b/docs/reference/config.md index 0b39bd0a..fb6c8c2d 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -10,7 +10,7 @@ Complete reference for all SkillOpt configuration parameters. | `model.optimizer` | str | `gpt-5.5` | Optimizer model (for reflection & slow update) | | `model.target` | str | `gpt-5.5` | Target model (for rollout execution) | | `model.reasoning_effort` | str | `medium` | Reasoning effort level | -| `model.optimizer_backend` | str | `openai_chat` | Optimizer backend: `openai_chat` / `claude_chat` / `qwen_chat` / `minimax_chat` | +| `model.optimizer_backend` | str | `openai_chat` | Optimizer backend: `openai_chat` / `claude_chat` / `qwen_chat` / `minimax_chat` / `codex_exec` | | `model.target_backend` | str | `openai_chat` | Target backend: chat backends plus execution harnesses | | `model.qwen_chat_base_url` | str | `http://localhost:8000/v1` | Shared Qwen/vLLM OpenAI-compatible endpoint | | `model.qwen_chat_enable_thinking` | bool | `false` | Shared Qwen thinking flag | diff --git a/scripts/eval_only.py b/scripts/eval_only.py index 79dfab09..f3c5f86f 100644 --- a/scripts/eval_only.py +++ b/scripts/eval_only.py @@ -320,8 +320,10 @@ def _has_model_override(dotted_key: str, legacy_key: str) -> bool: cfg.setdefault("optimizer_backend", "claude_chat") cfg.setdefault("target_backend", "claude_chat") elif backend in {"codex", "codex_exec"}: - cfg.setdefault("optimizer_backend", "openai_chat") - cfg.setdefault("target_backend", "codex_exec") + if not _has_model_override("model.optimizer_backend", "optimizer_backend"): + cfg["optimizer_backend"] = "codex_exec" + if not _has_model_override("model.target_backend", "target_backend"): + cfg["target_backend"] = "codex_exec" elif backend == "claude_code_exec": cfg.setdefault("optimizer_backend", "openai_chat") cfg.setdefault("target_backend", "claude_code_exec") diff --git a/scripts/train.py b/scripts/train.py index 5c0621ac..918e684a 100644 --- a/scripts/train.py +++ b/scripts/train.py @@ -438,8 +438,10 @@ def _has_model_override(dotted_key: str, legacy_key: str) -> bool: flat.setdefault("optimizer_backend", "claude_chat") flat.setdefault("target_backend", "claude_chat") elif backend in {"codex", "codex_exec"}: - flat.setdefault("optimizer_backend", "openai_chat") - flat.setdefault("target_backend", "codex_exec") + if not _has_model_override("model.optimizer_backend", "optimizer_backend"): + flat["optimizer_backend"] = "codex_exec" + if not _has_model_override("model.target_backend", "target_backend"): + flat["target_backend"] = "codex_exec" elif backend == "claude_code_exec": flat.setdefault("optimizer_backend", "openai_chat") flat.setdefault("target_backend", "claude_code_exec") diff --git a/skillopt/engine/trainer.py b/skillopt/engine/trainer.py index 85aae53c..39c24d86 100644 --- a/skillopt/engine/trainer.py +++ b/skillopt/engine/trainer.py @@ -670,12 +670,14 @@ def _build_eval_env(split: str, env_num: int, seed: int): optimizer_backend = cfg.get("optimizer_backend") target_backend = cfg.get("target_backend") if not optimizer_backend or not target_backend: - if backend in {"claude", "claude_chat"}: - optimizer_backend = optimizer_backend or "claude_chat" - target_backend = target_backend or "claude_chat" - elif backend in {"codex", "codex_exec"}: - optimizer_backend = optimizer_backend or "openai_chat" - target_backend = target_backend or "codex_exec" + if backend in {"claude", "claude_chat"}: + optimizer_backend = optimizer_backend or "claude_chat" + target_backend = target_backend or "claude_chat" + elif backend in {"codex", "codex_exec"}: + if optimizer_backend in (None, "", "openai_chat"): + optimizer_backend = "codex_exec" + if target_backend in (None, "", "openai_chat"): + target_backend = "codex_exec" elif backend == "claude_code_exec": optimizer_backend = optimizer_backend or "openai_chat" target_backend = target_backend or "claude_code_exec" diff --git a/skillopt/envs/_template/config_template.yaml b/skillopt/envs/_template/config_template.yaml index b482cc71..d98851dc 100644 --- a/skillopt/envs/_template/config_template.yaml +++ b/skillopt/envs/_template/config_template.yaml @@ -50,6 +50,6 @@ evaluation: # ── Model ──────────────────────────────────────── # Override only what differs from the inherited defaults. model: - optimizer_backend: openai_chat # openai_chat | claude_chat | qwen_chat | minimax_chat - target_backend: openai_chat # … plus codex_exec / claude_code_exec for target only + optimizer_backend: openai_chat # openai_chat | claude_chat | qwen_chat | minimax_chat | codex_exec + target_backend: openai_chat # chat backends plus codex_exec / claude_code_exec reasoning_effort: medium diff --git a/skillopt/model/__init__.py b/skillopt/model/__init__.py index a09e6e0c..31a9f14b 100644 --- a/skillopt/model/__init__.py +++ b/skillopt/model/__init__.py @@ -6,6 +6,7 @@ from skillopt.model import azure_openai as _openai from skillopt.model import claude_backend as _claude +from skillopt.model import codex_backend as _codex from skillopt.model import minimax_backend as _minimax from skillopt.model import qwen_backend as _qwen from skillopt.model.backend_config import ( # noqa: F401 @@ -40,10 +41,14 @@ def set_backend(name: str | None) -> str: set_target_backend("claude_chat") return "claude_chat" if normalized == "codex": - set_optimizer_backend("openai_chat") + set_optimizer_backend("codex_exec") set_target_backend("codex_exec") return "codex" - if normalized in {"codex_exec", "claude_code_exec"}: + if normalized == "codex_exec": + set_optimizer_backend("codex_exec") + set_target_backend("codex_exec") + return normalized + if normalized == "claude_code_exec": set_optimizer_backend("openai_chat") set_target_backend(normalized) return normalized @@ -68,7 +73,7 @@ def get_backend_name() -> str: return "qwen_chat" if optimizer == "openai_chat" and target == "openai_chat": return "azure_openai" - if optimizer == "openai_chat" and target == "codex_exec": + if optimizer == "codex_exec" and target == "codex_exec": return "codex" if optimizer == "openai_chat" and target == "qwen_chat": return "qwen_chat" @@ -105,6 +110,15 @@ def chat_optimizer( reasoning_effort=reasoning_effort, timeout=timeout, ) + if get_optimizer_backend() == "codex_exec": + return _codex.chat_optimizer( + system=system, + user=user, + max_completion_tokens=max_completion_tokens, + retries=retries, + stage=stage, + timeout=timeout, + ) return _openai.chat_optimizer( system=system, user=user, @@ -204,6 +218,17 @@ def chat_optimizer_messages( return_message=return_message, timeout=timeout, ) + if get_optimizer_backend() == "codex_exec": + return _codex.chat_optimizer_messages( + messages=messages, + max_completion_tokens=max_completion_tokens, + retries=retries, + stage=stage, + tools=tools, + tool_choice=tool_choice, + return_message=return_message, + timeout=timeout, + ) return _openai.chat_optimizer_messages( messages=messages, max_completion_tokens=max_completion_tokens, @@ -365,6 +390,17 @@ def get_token_summary() -> dict: summary[stage]["prompt_tokens"] += values["prompt_tokens"] summary[stage]["completion_tokens"] += values["completion_tokens"] summary[stage]["total_tokens"] += values["total_tokens"] + codex_summary = _codex.get_token_summary() + for stage, values in codex_summary.items(): + if stage == "_total": + continue + if stage not in summary: + summary[stage] = values + continue + summary[stage]["calls"] += values["calls"] + summary[stage]["prompt_tokens"] += values["prompt_tokens"] + summary[stage]["completion_tokens"] += values["completion_tokens"] + summary[stage]["total_tokens"] += values["total_tokens"] total = { "calls": 0, "prompt_tokens": 0, @@ -387,6 +423,7 @@ def reset_token_tracker() -> None: _claude.reset_token_tracker() _qwen.reset_token_tracker() _minimax.reset_token_tracker() + _codex.reset_token_tracker() def configure_azure_openai( @@ -499,6 +536,7 @@ def set_reasoning_effort(effort: str | None) -> None: _claude.set_reasoning_effort(effort) _qwen.set_reasoning_effort(effort) _minimax.set_reasoning_effort(effort) + _codex.set_reasoning_effort(effort) def set_target_deployment(deployment: str) -> None: @@ -506,9 +544,11 @@ def set_target_deployment(deployment: str) -> None: _claude.set_target_deployment(deployment) _qwen.set_target_deployment(deployment) _minimax.set_target_deployment(deployment) + _codex.set_target_deployment(deployment) def set_optimizer_deployment(deployment: str) -> None: _openai.set_optimizer_deployment(deployment) _claude.set_optimizer_deployment(deployment) _qwen.set_optimizer_deployment(deployment) + _codex.set_optimizer_deployment(deployment) diff --git a/skillopt/model/backend_config.py b/skillopt/model/backend_config.py index f23725c5..d90cd2d4 100644 --- a/skillopt/model/backend_config.py +++ b/skillopt/model/backend_config.py @@ -49,10 +49,10 @@ def _parse_int(value: str | None, default: int) -> int: def set_optimizer_backend(backend: str) -> None: global OPTIMIZER_BACKEND OPTIMIZER_BACKEND = normalize_backend_name(backend or "openai_chat") - if OPTIMIZER_BACKEND not in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat"}: + if OPTIMIZER_BACKEND not in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat", "codex_exec"}: raise ValueError( f"Unsupported optimizer backend: {OPTIMIZER_BACKEND!r}. " - "Supported values are 'openai_chat', 'claude_chat', 'qwen_chat', and 'minimax_chat'." + "Supported values are 'openai_chat', 'claude_chat', 'qwen_chat', 'minimax_chat', and 'codex_exec'." ) os.environ["OPTIMIZER_BACKEND"] = OPTIMIZER_BACKEND @@ -81,7 +81,7 @@ def is_target_exec_backend() -> bool: def is_optimizer_chat_backend() -> bool: - return OPTIMIZER_BACKEND in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat"} + return OPTIMIZER_BACKEND in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat", "codex_exec"} def is_target_chat_backend() -> bool: diff --git a/skillopt/model/codex_backend.py b/skillopt/model/codex_backend.py index 64b6f355..ff7f79cd 100644 --- a/skillopt/model/codex_backend.py +++ b/skillopt/model/codex_backend.py @@ -18,6 +18,7 @@ CompatToolFunction, tracker, ) +from skillopt.model.backend_config import get_codex_exec_config CODEX_BIN = os.environ.get("CODEX_CLI_BIN", "codex") @@ -286,20 +287,21 @@ def _run_codex_exec( timeout: int | None, ) -> tuple[str, dict[str, int]]: with tempfile.TemporaryDirectory(prefix="skillopt_codex_") as temp_dir: + config = get_codex_exec_config() output_path = os.path.join(temp_dir, "last_message.txt") image_paths = _materialize_attachments(attachments, temp_dir) + profile = str(config.get("profile") or os.environ.get("CODEX_PROFILE", "")).strip() + reasoning_effort = str(REASONING_EFFORT or config.get("reasoning_effort") or "").strip() command = [ - CODEX_BIN, + str(config.get("path") or CODEX_BIN), "exec", "--json", "--ephemeral", - "--profile", - CODEX_PROFILE, "-c", - "approval_policy=\"never\"", + f"approval_policy={json.dumps(str(config.get('approval_policy') or 'never'))}", "--sandbox", - CODEX_SANDBOX_MODE, + str(config.get("sandbox") or CODEX_SANDBOX_MODE), "--skip-git-repo-check", "--cd", _default_working_directory(), @@ -309,8 +311,11 @@ def _run_codex_exec( output_path, ] - if REASONING_EFFORT: - command.extend(["-c", f"model_reasoning_effort={json.dumps(REASONING_EFFORT)}"]) + if profile: + command.extend(["--profile", profile]) + + if reasoning_effort and reasoning_effort != "none": + command.extend(["-c", f"model_reasoning_effort={json.dumps(reasoning_effort)}"]) schema_path = None if output_schema is not None: diff --git a/tests/test_codex_optimizer_backend.py b/tests/test_codex_optimizer_backend.py new file mode 100644 index 00000000..08677a2e --- /dev/null +++ b/tests/test_codex_optimizer_backend.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import importlib.util +import os +import sys +import types +from collections.abc import Iterator +from typing import Any + +import pytest + + +class _OpenAIClientStub: + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.args = args + self.kwargs = kwargs + + +def _install_openai_stub() -> None: + if "openai" in sys.modules or importlib.util.find_spec("openai") is not None: + return + openai_stub = types.ModuleType("openai") + openai_stub.AzureOpenAI = _OpenAIClientStub + openai_stub.OpenAI = _OpenAIClientStub + sys.modules["openai"] = openai_stub + + +def _import_model_modules() -> tuple[Any, Any, Any, Any]: + _install_openai_stub() + import skillopt.model as model_module + from skillopt.model import azure_openai, backend_config, codex_backend + + return model_module, backend_config, codex_backend, azure_openai + + +@pytest.fixture(autouse=True) +def isolate_backend_state() -> Iterator[tuple[Any, Any, Any, Any]]: + model_module, backend_config, codex_backend, azure_openai = _import_model_modules() + optimizer_backend = backend_config.get_optimizer_backend() + target_backend = backend_config.get_target_backend() + env = { + key: os.environ.get(key) + for key in ( + "OPTIMIZER_BACKEND", + "TARGET_BACKEND", + "OPTIMIZER_DEPLOYMENT", + "TARGET_DEPLOYMENT", + ) + } + yield model_module, backend_config, codex_backend, azure_openai + backend_config.set_optimizer_backend(optimizer_backend) + backend_config.set_target_backend(target_backend) + for key, value in env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def test_codex_exec_can_be_optimizer_backend( + isolate_backend_state: tuple[Any, Any, Any, Any], +) -> None: + _model_module, backend_config, _codex_backend, _azure_openai = isolate_backend_state + + backend_config.set_optimizer_backend("codex_exec") + + assert backend_config.get_optimizer_backend() == "codex_exec" + + +def test_set_backend_codex_uses_codex_for_optimizer_and_target( + isolate_backend_state: tuple[Any, Any, Any, Any], +) -> None: + model_module, backend_config, _codex_backend, _azure_openai = isolate_backend_state + + assert model_module.set_backend("codex") == "codex" + + assert backend_config.get_optimizer_backend() == "codex_exec" + assert backend_config.get_target_backend() == "codex_exec" + assert model_module.get_backend_name() == "codex" + + +def test_chat_optimizer_routes_to_codex_backend( + monkeypatch: pytest.MonkeyPatch, + isolate_backend_state: tuple[Any, Any, Any, Any], +) -> None: + model_module, backend_config, codex_backend, azure_openai = isolate_backend_state + codex_calls: list[dict[str, Any]] = [] + + def fake_codex_optimizer(**kwargs: Any) -> tuple[str, dict[str, int]]: + codex_calls.append(kwargs) + return "codex result", { + "prompt_tokens": 1, + "completion_tokens": 2, + "total_tokens": 3, + } + + def fail_openai_optimizer(**_kwargs: Any) -> tuple[str, dict[str, int]]: + raise AssertionError("openai optimizer should not be called for codex_exec") + + monkeypatch.setattr(codex_backend, "chat_optimizer", fake_codex_optimizer) + monkeypatch.setattr(azure_openai, "chat_optimizer", fail_openai_optimizer) + backend_config.set_optimizer_backend("codex_exec") + + text, usage = model_module.chat_optimizer("system", "user", retries=1, timeout=5) + + assert text == "codex result" + assert usage["total_tokens"] == 3 + assert codex_calls[0]["system"] == "system" + assert codex_calls[0]["user"] == "user" + assert codex_calls[0]["timeout"] == 5