Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/guideline.html
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ <h2>1.3 Key Features <a class="anchor" href="#features">#</a></h2>
<div class="card"><h4>Validation gating</h4><p>Every candidate skill is scored on a held-out selection split and only accepted if it beats the current/best skill.</p></div>
<div class="card"><h4>Slow update</h4><p>Epoch-boundary longitudinal comparison writes guidance into a protected region — momentum against forgetting. Force-injected or selection-gated.</p></div>
<div class="card"><h4>Meta skill</h4><p>Optimizer-side memory that reflects on what worked across epochs and feeds back into reflection.</p></div>
<div class="card"><h4>Pluggable backends</h4><p>OpenAI / Azure OpenAI, Anthropic Claude, local Qwen (vLLM), plus Codex/Claude-Code exec backends for the target.</p></div>
<div class="card"><h4>Pluggable backends</h4><p>OpenAI / Azure OpenAI, Anthropic Claude, local Qwen (vLLM), plus Codex exec for optimizer and target, and Claude-Code exec for the target.</p></div>
<div class="card"><h4>Six benchmarks</h4><p>SearchQA, DocVQA, ALFWorld, LiveMathematicianBench, SpreadsheetBench, OfficeQA — each a self-contained env module.</p></div>
<div class="card"><h4>Auto-resume</h4><p>Every run is checkpointed step-by-step; re-running the same command continues from the last completed step.</p></div>
</div>
Expand Down Expand Up @@ -727,7 +727,7 @@ <h2>6.2 <code>model.*</code> <a class="anchor" href="#cfg-model">#</a></h2>
<tr><td><code>backend</code></td><td>str</td><td class="def">azure_openai</td><td>High-level backend label for the run.</td></tr>
<tr><td><code>optimizer</code></td><td>str</td><td class="def">gpt-5.5</td><td>Optimizer model deployment (writes skill edits).</td></tr>
<tr><td><code>target</code></td><td>str</td><td class="def">gpt-5.5</td><td>Target model deployment (executes tasks).</td></tr>
<tr><td><code>optimizer_backend</code></td><td>str</td><td class="def">openai_chat</td><td>Client path for the optimizer: <code>openai_chat</code> or <code>claude_chat</code>.</td></tr>
<tr><td><code>optimizer_backend</code></td><td>str</td><td class="def">openai_chat</td><td>Client path for the optimizer: <code>openai_chat</code> / <code>claude_chat</code> / <code>qwen_chat</code> / <code>minimax_chat</code> / <code>codex_exec</code>.</td></tr>
<tr><td><code>target_backend</code></td><td>str</td><td class="def">openai_chat</td><td>Client path for the target: <code>openai_chat</code> / <code>claude_chat</code> / <code>qwen_chat</code> / <code>codex_exec</code> / <code>claude_code_exec</code>.</td></tr>
<tr><td><code>reasoning_effort</code></td><td>str</td><td class="def">medium</td><td><code>low</code> / <code>medium</code> / <code>high</code> / <code>xhigh</code> / <code>max</code> (or empty).</td></tr>
<tr><td><code>rewrite_reasoning_effort</code></td><td>str</td><td class="def">""</td><td>Override effort for full-rewrite calls (empty = inherit).</td></tr>
Expand All @@ -738,7 +738,7 @@ <h2>6.2 <code>model.*</code> <a class="anchor" href="#cfg-model">#</a></h2>
</tbody>
</table></div>
<div class="note info"><span class="nh">Separate optimizer / target endpoints</span>
<p>Every <code>azure_openai_*</code> key also has <code>optimizer_azure_openai_*</code> and <code>target_azure_openai_*</code> variants, letting you point the optimizer and target at different Azure resources. Exec backends (<code>codex_exec</code>, <code>claude_code_exec</code>) add their own <code>codex_exec_*</code> / <code>claude_code_exec_*</code> knobs (sandbox, reasoning effort, SDK mode, etc.).</p>
<p>Every <code>azure_openai_*</code> key also has <code>optimizer_azure_openai_*</code> and <code>target_azure_openai_*</code> variants, letting you point the optimizer and target at different Azure resources. Exec backends add their own knobs, including <code>codex_exec_*</code> for optimizer/target Codex runs and <code>claude_code_exec_*</code> for target Claude-Code runs (sandbox, reasoning effort, SDK mode, etc.).</p>
</div>
</section>

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 4 additions & 2 deletions scripts/eval_only.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 4 additions & 2 deletions scripts/train.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
14 changes: 8 additions & 6 deletions skillopt/engine/trainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions skillopt/envs/_template/config_template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
46 changes: 43 additions & 3 deletions skillopt/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -499,16 +536,19 @@ 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:
_openai.set_target_deployment(deployment)
_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)
6 changes: 3 additions & 3 deletions skillopt/model/backend_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
19 changes: 12 additions & 7 deletions skillopt/model/codex_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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(),
Expand All @@ -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:
Expand Down
110 changes: 110 additions & 0 deletions tests/test_codex_optimizer_backend.py
Original file line number Diff line number Diff line change
@@ -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