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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions evolve_server/engines/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -984,8 +984,34 @@ async def run_once(self) -> dict:
queued_candidates,
elapsed,
)
if uploaded_skills > 0:
await self._notify_proxy_reload()
return summary

async def _notify_proxy_reload(self) -> None:
mode = str(getattr(self.config, "skill_reload_mode", "") or "poll").strip().lower()
url = str(getattr(self.config, "proxy_reload_url", "") or "").strip().rstrip("/")
if mode != "callback" or not url:
return
headers: dict[str, str] = {}
api_key = str(getattr(self.config, "proxy_reload_api_key", "") or "")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
import httpx

for attempt in range(3):
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.post(f"{url}/internal/reload-skills", headers=headers)
resp.raise_for_status()
logger.info("[EvolveServer] notified proxy to reload skills: %s", url)
return
except Exception as exc:
if attempt < 2:
await asyncio.sleep(1.0 * (attempt + 1))
else:
logger.warning("[EvolveServer] proxy reload notify failed after 3 attempts: %s", exc)

async def run_periodic(self) -> None:
self._running = True
logger.info("[EvolveServer] periodic mode: interval=%ds", self.config.interval_seconds)
Expand Down
403 changes: 366 additions & 37 deletions skillclaw/api_server.py

Large diffs are not rendered by default.

34 changes: 25 additions & 9 deletions skillclaw/claw_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
_HERMES_BACKUP_DIR = Path.home() / ".skillclaw" / "backups" / "hermes"
_CODEX_HOME = Path.home() / ".codex"
_CODEX_CONFIG_PATH = _CODEX_HOME / "config.toml"
_CODEX_PROFILE_CONFIG_PATH = _CODEX_HOME / "skillclaw.config.toml"
_CODEX_SKILLS_DIR = _CODEX_HOME / "skills"
_CODEX_BACKUP_DIR = Path.home() / ".skillclaw" / "backups" / "codex"
_CLAUDE_HOME = Path.home() / ".claude"
Expand Down Expand Up @@ -656,7 +657,6 @@ def _build_codex_provider_block(base_url: str, api_key: str) -> str:

def _build_codex_profile_block(model_id: str) -> str:
lines = [
"[profiles.skillclaw]",
f"model = {_format_toml_value(model_id)}",
'model_provider = "skillclaw"',
]
Expand All @@ -673,6 +673,7 @@ def _configure_codex(cfg: "SkillClawConfig") -> None:
api_key = cfg.proxy_api_key or "skillclaw"
base_url = f"http://127.0.0.1:{cfg.proxy_port}/v1"
config_path = _CODEX_CONFIG_PATH
profile_config_path = _CODEX_PROFILE_CONFIG_PATH
_prepare_external_skills_dir(_CODEX_SKILLS_DIR, "Codex")

existing_text = ""
Expand All @@ -687,16 +688,17 @@ def _configure_codex(cfg: "SkillClawConfig") -> None:
updated = _remove_top_level_toml_keys(updated, {"model", "model_provider"})
updated = _remove_toml_table(updated, "model_providers.skillclaw").rstrip() + "\n\n"
updated = _remove_toml_table(updated, "profiles.skillclaw").rstrip() + "\n\n"
updated += _build_codex_provider_block(base_url, api_key)
updated += "\n" + _build_codex_profile_block(model_id)
profile_text = _build_codex_profile_block(model_id) + "\n" + _build_codex_provider_block(base_url, api_key)

_backup_codex_config_if_changed(config_path, updated)
_write_text_atomic(config_path, updated, "Codex config")
_write_text_atomic(profile_config_path, profile_text, "Codex SkillClaw profile config")


def inspect_codex_config(cfg: "SkillClawConfig") -> dict[str, object]:
"""Return a diagnostic snapshot of the local Codex integration state."""
config_path = _CODEX_CONFIG_PATH
profile_config_path = _CODEX_PROFILE_CONFIG_PATH
expected_model = cfg.served_model_name or cfg.llm_model_id or "skillclaw-model"
expected_base_url = f"http://127.0.0.1:{cfg.proxy_port}/v1"
expected_api_key = cfg.proxy_api_key or "skillclaw"
Expand All @@ -711,16 +713,21 @@ def inspect_codex_config(cfg: "SkillClawConfig") -> dict[str, object]:
text = config_path.read_text(encoding="utf-8")
except Exception as e:
logger.warning("[ClawAdapter] Failed to read Codex config %s: %s", config_path, e)
profile_text = ""
if profile_config_path.exists():
try:
profile_text = profile_config_path.read_text(encoding="utf-8")
except Exception as e:
logger.warning("[ClawAdapter] Failed to read Codex profile config %s: %s", profile_config_path, e)

configured_model = str(_extract_top_level_toml_value(text, "model") or "")
configured_provider = str(_extract_top_level_toml_value(text, "model_provider") or "")
provider_cfg = _extract_toml_table(text, "model_providers.skillclaw")
provider_cfg = _extract_toml_table(profile_text, "model_providers.skillclaw")
configured_base_url = str(provider_cfg.get("base_url") or "")
configured_wire_api = str(provider_cfg.get("wire_api") or "")
configured_token = str(provider_cfg.get("experimental_bearer_token") or "")
profile_cfg = _extract_toml_table(text, "profiles.skillclaw")
configured_profile_model = str(profile_cfg.get("model") or "")
configured_profile_provider = str(profile_cfg.get("model_provider") or "")
configured_profile_model = str(_extract_top_level_toml_value(profile_text, "model") or "")
configured_profile_provider = str(_extract_top_level_toml_value(profile_text, "model_provider") or "")

proxy_match = (
configured_profile_model == expected_model
Expand All @@ -743,9 +750,13 @@ def inspect_codex_config(cfg: "SkillClawConfig") -> dict[str, object]:

if not config_path.exists():
issues.append("Codex config is missing: ~/.codex/config.toml")
if not profile_config_path.exists():
issues.append("Codex SkillClaw profile config is missing: ~/.codex/skillclaw.config.toml")
if not proxy_match:
issues.append("Codex SkillClaw profile is missing or not pointing at the local SkillClaw proxy.")
next_steps.append("Start SkillClaw once with `claw_type=codex` so it can register ~/.codex/config.toml.")
next_steps.append(
"Start SkillClaw once with `claw_type=codex` so it can register ~/.codex/skillclaw.config.toml."
)
if configured_provider == "skillclaw":
issues.append("Codex global model_provider still points at SkillClaw; normal Codex runs may be intercepted.")
next_steps.append('Remove top-level `model_provider = "skillclaw"` or run `skillclaw restore codex`.')
Expand Down Expand Up @@ -797,7 +808,12 @@ def restore_codex_config(backup_path: Path | None = None) -> dict[str, str]:
text = source.read_text(encoding="utf-8")
target = _CODEX_CONFIG_PATH
_write_text_atomic(target, text, "Codex config restore")
return {"source": str(source), "target": str(target)}
profile_target = _CODEX_PROFILE_CONFIG_PATH
removed_profile = False
if profile_target.exists():
profile_target.unlink()
removed_profile = True
return {"source": str(source), "target": str(target), "removed_profile": str(removed_profile)}


# ------------------------------------------------------------------ #
Expand Down
2 changes: 2 additions & 0 deletions skillclaw/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,8 @@ def restore_codex(backup_path: str | None):
raise click.ClickException(str(exc)) from None

click.echo(f"Restored Codex config: {result['target']} <- {result['source']}")
if result.get("removed_profile") == "True":
click.echo("Removed Codex SkillClaw profile config: ~/.codex/skillclaw.config.toml")


@restore.command(name="claude")
Expand Down
97 changes: 88 additions & 9 deletions tests/test_codex_profile_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ def record_injection(self, names: list[str]) -> None:

def test_configure_codex_registers_profile_without_replacing_global_defaults(monkeypatch, tmp_path: Path) -> None:
config_path = tmp_path / ".codex" / "config.toml"
profile_config_path = tmp_path / ".codex" / "skillclaw.config.toml"
config_path.parent.mkdir(parents=True)
config_path.write_text(
'model = "gpt-5.5"\nmodel_provider = "openai"\n\n[profiles.default]\nmodel = "gpt-5.5"\n',
encoding="utf-8",
)
monkeypatch.setattr(claw_adapter, "_CODEX_CONFIG_PATH", config_path)
monkeypatch.setattr(claw_adapter, "_CODEX_PROFILE_CONFIG_PATH", profile_config_path)
monkeypatch.setattr(claw_adapter, "_CODEX_SKILLS_DIR", tmp_path / ".codex" / "skills")
monkeypatch.setattr(claw_adapter, "_CODEX_BACKUP_DIR", tmp_path / "backups")

Expand All @@ -47,24 +49,38 @@ def test_configure_codex_registers_profile_without_replacing_global_defaults(mon
text = config_path.read_text(encoding="utf-8")
assert 'model = "gpt-5.5"' in text
assert 'model_provider = "openai"' in text
assert "[model_providers.skillclaw]" in text
assert 'base_url = "http://127.0.0.1:31000/v1"' in text
assert 'wire_api = "responses"' in text
assert 'experimental_bearer_token = "skillclaw-key"' in text
assert "[profiles.skillclaw]" in text
assert 'model = "skillclaw-model"' in text
assert 'model_provider = "skillclaw"' in text
assert "[profiles.skillclaw]" not in text
assert "[model_providers.skillclaw]" not in text
profile_text = profile_config_path.read_text(encoding="utf-8")
assert 'model = "skillclaw-model"' in profile_text
assert 'model_provider = "skillclaw"' in profile_text
assert "[model_providers.skillclaw]" in profile_text
assert 'base_url = "http://127.0.0.1:31000/v1"' in profile_text
assert 'wire_api = "responses"' in profile_text
assert 'experimental_bearer_token = "skillclaw-key"' in profile_text
assert (tmp_path / ".codex" / "skills").is_dir()


def test_configure_codex_removes_legacy_global_skillclaw_defaults(monkeypatch, tmp_path: Path) -> None:
config_path = tmp_path / ".codex" / "config.toml"
profile_config_path = tmp_path / ".codex" / "skillclaw.config.toml"
config_path.parent.mkdir(parents=True)
config_path.write_text(
'model = "skillclaw-model"\nmodel_provider = "skillclaw"\n\n[profiles.default]\nmodel = "gpt-5.5"\n',
(
'model = "skillclaw-model"\n'
'model_provider = "skillclaw"\n\n'
"[model_providers.skillclaw]\n"
'base_url = "http://127.0.0.1:30000/v1"\n\n'
"[profiles.skillclaw]\n"
'model = "skillclaw-model"\n'
'model_provider = "skillclaw"\n\n'
"[profiles.default]\n"
'model = "gpt-5.5"\n'
),
encoding="utf-8",
)
monkeypatch.setattr(claw_adapter, "_CODEX_CONFIG_PATH", config_path)
monkeypatch.setattr(claw_adapter, "_CODEX_PROFILE_CONFIG_PATH", profile_config_path)
monkeypatch.setattr(claw_adapter, "_CODEX_SKILLS_DIR", tmp_path / ".codex" / "skills")
monkeypatch.setattr(claw_adapter, "_CODEX_BACKUP_DIR", tmp_path / "backups")

Expand All @@ -73,7 +89,70 @@ def test_configure_codex_removes_legacy_global_skillclaw_defaults(monkeypatch, t
top_level = config_path.read_text(encoding="utf-8").split("[", 1)[0]
assert "model_provider" not in top_level
assert "model =" not in top_level
assert "[profiles.skillclaw]" in config_path.read_text(encoding="utf-8")
text = config_path.read_text(encoding="utf-8")
assert "[profiles.skillclaw]" not in text
assert "[model_providers.skillclaw]" not in text
assert "[profiles.default]" in text
assert "[model_providers.skillclaw]" in profile_config_path.read_text(encoding="utf-8")


def test_inspect_codex_config_reads_split_profile_config(monkeypatch, tmp_path: Path) -> None:
config_path = tmp_path / ".codex" / "config.toml"
profile_config_path = tmp_path / ".codex" / "skillclaw.config.toml"
skills_dir = tmp_path / ".codex" / "skills"
config_path.parent.mkdir(parents=True)
skills_dir.mkdir()
config_path.write_text('model = "gpt-5.5"\nmodel_provider = "openai"\n', encoding="utf-8")
profile_config_path.write_text(
(
'model = "skillclaw-model"\n'
'model_provider = "skillclaw"\n\n'
"[model_providers.skillclaw]\n"
'name = "SkillClaw"\n'
'base_url = "http://127.0.0.1:31000/v1"\n'
'wire_api = "responses"\n'
'experimental_bearer_token = "skillclaw-key"\n'
),
encoding="utf-8",
)
monkeypatch.setattr(claw_adapter, "_CODEX_CONFIG_PATH", config_path)
monkeypatch.setattr(claw_adapter, "_CODEX_PROFILE_CONFIG_PATH", profile_config_path)
monkeypatch.setattr(claw_adapter, "_CODEX_SKILLS_DIR", skills_dir)
monkeypatch.setattr(claw_adapter, "_CODEX_BACKUP_DIR", tmp_path / "backups")

report = claw_adapter.inspect_codex_config(
SkillClawConfig(
served_model_name="skillclaw-model",
proxy_api_key="skillclaw-key",
proxy_port=31000,
skills_dir=str(skills_dir),
)
)

assert report["status"] == "ok"
assert report["proxy_match"] is True
assert report["configured_profile_model"] == "skillclaw-model"
assert report["configured_base_url"] == "http://127.0.0.1:31000/v1"


def test_restore_codex_config_removes_split_profile_config(monkeypatch, tmp_path: Path) -> None:
config_path = tmp_path / ".codex" / "config.toml"
profile_config_path = tmp_path / ".codex" / "skillclaw.config.toml"
backup_path = tmp_path / "backups" / "config.latest.toml"
config_path.parent.mkdir(parents=True)
backup_path.parent.mkdir(parents=True)
config_path.write_text('model_provider = "skillclaw"\n', encoding="utf-8")
profile_config_path.write_text('model_provider = "skillclaw"\n', encoding="utf-8")
backup_path.write_text('model_provider = "openai"\n', encoding="utf-8")
monkeypatch.setattr(claw_adapter, "_CODEX_CONFIG_PATH", config_path)
monkeypatch.setattr(claw_adapter, "_CODEX_PROFILE_CONFIG_PATH", profile_config_path)
monkeypatch.setattr(claw_adapter, "_CODEX_BACKUP_DIR", backup_path.parent)

result = claw_adapter.restore_codex_config()

assert config_path.read_text(encoding="utf-8") == 'model_provider = "openai"\n'
assert not profile_config_path.exists()
assert result["removed_profile"] == "True"


def test_codex_config_defaults_to_responses_mode_and_codex_skills(tmp_path: Path) -> None:
Expand Down
107 changes: 107 additions & 0 deletions tests/test_evolve_proxy_reload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
from __future__ import annotations

import types

import pytest

from evolve_server.core.config import EvolveServerConfig
from evolve_server.engines.workflow import EvolveServer


@pytest.mark.anyio
async def test_notify_proxy_reload_posts_callback_with_auth(monkeypatch) -> None:
server = EvolveServer.__new__(EvolveServer)
server.config = EvolveServerConfig(
skill_reload_mode="callback",
proxy_reload_url="http://proxy.test/",
proxy_reload_api_key="secret",
)
captured = {}

class FakeAsyncClient:
def __init__(self, *args, **kwargs):
captured["timeout"] = kwargs.get("timeout")

async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc, tb):
return False

async def post(self, url, headers):
captured["url"] = url
captured["headers"] = headers
return types.SimpleNamespace(raise_for_status=lambda: None)

fake_httpx = types.SimpleNamespace(AsyncClient=FakeAsyncClient)
monkeypatch.setitem(__import__("sys").modules, "httpx", fake_httpx)

await server._notify_proxy_reload()

assert captured == {
"timeout": 5.0,
"url": "http://proxy.test/internal/reload-skills",
"headers": {"Authorization": "Bearer secret"},
}


@pytest.mark.anyio
async def test_notify_proxy_reload_retries_on_http_error(monkeypatch) -> None:
server = EvolveServer.__new__(EvolveServer)
server.config = EvolveServerConfig(
skill_reload_mode="callback",
proxy_reload_url="http://proxy.test",
proxy_reload_api_key="secret",
)
attempts = {"count": 0}

class FakeResponse:
def raise_for_status(self):
raise RuntimeError("401 Unauthorized")

class FakeAsyncClient:
def __init__(self, *args, **kwargs):
pass

async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc, tb):
return False

async def post(self, url, headers):
attempts["count"] += 1
return FakeResponse()

async def fake_sleep(_delay):
return None

fake_httpx = types.SimpleNamespace(AsyncClient=FakeAsyncClient)
monkeypatch.setitem(__import__("sys").modules, "httpx", fake_httpx)
monkeypatch.setattr("evolve_server.engines.workflow.asyncio.sleep", fake_sleep)

await server._notify_proxy_reload()

assert attempts == {"count": 3}


@pytest.mark.anyio
async def test_notify_proxy_reload_skips_non_callback_modes(monkeypatch) -> None:
server = EvolveServer.__new__(EvolveServer)
server.config = EvolveServerConfig(
skill_reload_mode="poll",
proxy_reload_url="http://proxy.test",
proxy_reload_api_key="secret",
)
called = {"http": False}

class FakeAsyncClient:
def __init__(self, *args, **kwargs):
called["http"] = True

fake_httpx = types.SimpleNamespace(AsyncClient=FakeAsyncClient)
monkeypatch.setitem(__import__("sys").modules, "httpx", fake_httpx)

await server._notify_proxy_reload()

assert called == {"http": False}
Loading
Loading