mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
fix(cron): resolve provider with the job's effective model; default dashboard cron creates to the backend's own profile
Two follow-ups to the per-job model pin surface (#67472 / #49948 review): - cron/scheduler.py: pass target_model=<effective job model> to resolve_runtime_provider() on the primary path, so providers with model-specific api_mode routing derive the mode from the model the job actually runs (per-job pin > env > config default) instead of the stale persisted default. The auth-fallback path already did this for its fb_model. - hermes_cli/web_server.py: POST /api/cron/jobs (and its sync worker) no longer hardcodes profile="default" when the request carries no profile param. A pool backend scoped to a named profile now resolves its own profile via get_active_profile_name(), so pre-profileScoped desktop clients can't write a named profile's job into ~/.hermes. Unscoped / custom HERMES_HOME keeps the legacy default fallback. Tests: target_model capture test on run_job; two profile-default tests on the create endpoint.
This commit is contained in:
parent
6e676c768c
commit
786df3ca6c
4 changed files with 117 additions and 3 deletions
|
|
@ -3178,6 +3178,11 @@ def run_job(
|
|||
# example DeepSeek) for cron jobs that do not pin provider/model.
|
||||
runtime_kwargs = {
|
||||
"requested": job.get("provider"),
|
||||
# Derive provider-specific api_mode from the model this job
|
||||
# will actually run (per-job pin > env > config default), not
|
||||
# the stale persisted default — mirrors the fallback path
|
||||
# below, which already passes its fb_model.
|
||||
"target_model": model,
|
||||
}
|
||||
if job.get("base_url"):
|
||||
runtime_kwargs["explicit_base_url"] = job.get("base_url")
|
||||
|
|
|
|||
|
|
@ -11096,11 +11096,31 @@ def _cron_profile_dicts() -> List[Dict[str, Any]]:
|
|||
return _fallback_profile_dicts(profiles_mod)
|
||||
|
||||
|
||||
def _cron_default_profile() -> str:
|
||||
"""Profile to target when a cron request carries no explicit ``profile``.
|
||||
|
||||
A desktop pool backend runs one process per profile (HERMES_HOME already
|
||||
scoped), but these cron endpoints deliberately route storage through the
|
||||
profiles tree via ``_cron_profile_home`` — so a hardcoded ``"default"``
|
||||
fallback would write a non-default profile's job into ``~/.hermes``.
|
||||
Resolve the process's own profile instead. ``custom`` (an unrecognized
|
||||
HERMES_HOME outside the profiles tree) has no profile-dir equivalent, so
|
||||
it keeps the legacy ``default`` fallback.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
|
||||
name = get_active_profile_name()
|
||||
except Exception:
|
||||
return "default"
|
||||
return "default" if name in ("default", "custom") else name
|
||||
|
||||
|
||||
def _cron_profile_home(profile: Optional[str]) -> Tuple[str, Path]:
|
||||
"""Resolve a profile query value to (profile_name, HERMES_HOME)."""
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
|
||||
raw = (profile or "default").strip() or "default"
|
||||
raw = (profile or _cron_default_profile()).strip() or "default"
|
||||
try:
|
||||
canon = profiles_mod.normalize_profile_name(raw)
|
||||
profiles_mod.validate_profile_name(canon)
|
||||
|
|
@ -11256,7 +11276,7 @@ async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit:
|
|||
return await _run_cron_dashboard_io(_list_cron_job_runs_sync, job_id, profile, limit)
|
||||
|
||||
|
||||
def _create_cron_job_sync(body: CronJobCreate, profile: str = "default"):
|
||||
def _create_cron_job_sync(body: CronJobCreate, profile: Optional[str] = None):
|
||||
try:
|
||||
profile_name, profile_home = _cron_profile_home(profile)
|
||||
script = _normalize_dashboard_cron_script(body.script, profile_home)
|
||||
|
|
@ -11295,7 +11315,7 @@ def _create_cron_job_sync(body: CronJobCreate, profile: str = "default"):
|
|||
|
||||
|
||||
@app.post("/api/cron/jobs")
|
||||
async def create_cron_job(body: CronJobCreate, profile: str = "default"):
|
||||
async def create_cron_job(body: CronJobCreate, profile: Optional[str] = None):
|
||||
return await _run_cron_dashboard_io(_create_cron_job_sync, body, profile)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -334,3 +334,42 @@ class TestModelDriftGuard:
|
|||
)
|
||||
assert agent_constructed is True
|
||||
assert success is True
|
||||
|
||||
|
||||
class TestRuntimeResolutionTargetModel:
|
||||
"""run_job must resolve the primary provider against the model the job
|
||||
will actually run (per-job pin > env > config default), so providers with
|
||||
model-specific api_mode routing (e.g. OpenCode Zen/Go) pick the mode for
|
||||
the pinned model instead of the stale persisted default."""
|
||||
|
||||
def test_primary_resolution_passes_effective_model(self, tmp_path):
|
||||
job = _base_job(model="my-pinned-model", provider="openrouter")
|
||||
captured = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"api_key": "test-key",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
fake_db = MagicMock()
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
side_effect=_capture,
|
||||
), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
run_job(job)
|
||||
|
||||
assert captured.get("target_model") == "my-pinned-model"
|
||||
assert captured.get("requested") == "openrouter"
|
||||
|
|
|
|||
|
|
@ -735,3 +735,53 @@ async def test_cron_profile_validation_errors(isolated_profiles):
|
|||
with pytest.raises(HTTPException) as missing:
|
||||
await web_server.list_cron_jobs(profile="missing_profile")
|
||||
assert missing.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_cron_job_without_profile_uses_backend_own_profile(
|
||||
isolated_profiles, monkeypatch
|
||||
):
|
||||
"""A pool backend scoped to a named profile must not default creates to
|
||||
``~/.hermes`` when the request carries no explicit ``profile`` (the
|
||||
Desktop app's pre-profileScoped clients sent none)."""
|
||||
from hermes_cli import web_server
|
||||
|
||||
monkeypatch.setenv(
|
||||
"HERMES_HOME", str(isolated_profiles["worker_alpha"])
|
||||
)
|
||||
|
||||
job = await web_server.create_cron_job(
|
||||
web_server.CronJobCreate(
|
||||
prompt="runs in my own profile",
|
||||
schedule="every 1h",
|
||||
name="own-profile-job",
|
||||
),
|
||||
profile=None,
|
||||
)
|
||||
|
||||
assert job["profile"] == "worker_alpha"
|
||||
assert (isolated_profiles["worker_alpha"] / "cron" / "jobs.json").exists()
|
||||
assert not (isolated_profiles["default"] / "cron" / "jobs.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_cron_job_without_profile_defaults_when_unscoped(
|
||||
isolated_profiles, monkeypatch
|
||||
):
|
||||
"""HERMES_HOME at the default home (or unrecognized) keeps the legacy
|
||||
``default`` fallback."""
|
||||
from hermes_cli import web_server
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(isolated_profiles["default"]))
|
||||
|
||||
job = await web_server.create_cron_job(
|
||||
web_server.CronJobCreate(
|
||||
prompt="runs in default",
|
||||
schedule="every 1h",
|
||||
name="default-job",
|
||||
),
|
||||
profile=None,
|
||||
)
|
||||
|
||||
assert job["profile"] == "default"
|
||||
assert (isolated_profiles["default"] / "cron" / "jobs.json").exists()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue