mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
feat(cron): user-owned model pins + cron.model fleet default
Per-job cron inference pins are now user-owned: the agent-facing cronjob tool schema no longer exposes model/provider/base_url, and the registered handler ignores them even if a model hallucinates the old parameters. Users set pins via the dashboard, hermes cron create/edit --model/--provider, or jobs.json directly — and once set, a pin sticks until the user changes it. Existing agent-era pins are grandfathered untouched. New cron.model / cron.model_provider config keys give the cron fleet its own default model, independent of the chat model. Fire-time resolution: per-job pin > cron.model > HERMES_MODEL > model.default. An axis covered by the explicit cron-fleet default is deliberate routing, not drift, so the #44585 fail-closed guard skips it — switching your chat model with /model or hermes model no longer breaks unpinned cron fleets. - tools/cronjob_tools.py: drop model param from agent schema + handler; remove now-dead _resolve_model_override - cron/scheduler.py: cron.model/model_provider resolution + per-axis drift-guard skip - cron/jobs.py: snapshot resolution mirrors the new precedence - hermes_cli/subcommands/cron.py + hermes_cli/cron.py: --model/--provider on hermes cron create/edit - hermes_cli/config.py: cron.model / cron.model_provider defaults - docs: cron.md model-resolution tip rewritten
This commit is contained in:
parent
156edc5ded
commit
d464ae3652
10 changed files with 362 additions and 111 deletions
|
|
@ -1119,6 +1119,13 @@ def _resolve_default_model_snapshot() -> Optional[str]:
|
|||
except Exception:
|
||||
pass
|
||||
cfg = _expand_env_vars(cfg)
|
||||
# Mirror run_job's precedence: the explicit cron-fleet default
|
||||
# (cron.model) beats the global chat model for unpinned cron jobs.
|
||||
cron_cfg = cfg.get("cron") or {}
|
||||
if isinstance(cron_cfg, dict):
|
||||
cron_model = cron_cfg.get("model")
|
||||
if isinstance(cron_model, str) and cron_model.strip():
|
||||
return cron_model.strip()
|
||||
model_cfg = cfg.get("model") or {}
|
||||
if isinstance(model_cfg, str):
|
||||
return model_cfg.strip() or None
|
||||
|
|
|
|||
|
|
@ -3150,13 +3150,21 @@ def run_job(
|
|||
else str(delivery_target["thread_id"])
|
||||
)
|
||||
|
||||
# Model resolution precedence: per-job override > HERMES_MODEL env >
|
||||
# config.yaml ``model:`` (string or ``{default: ...}``). The per-job
|
||||
# value is intentionally re-read from storage every tick so a
|
||||
# ``cronjob action=update model=...`` after a failed run takes effect
|
||||
# on the next tick — there is no in-memory cache.
|
||||
# Model resolution precedence: per-job override > cron.model (the
|
||||
# cron-fleet default) > HERMES_MODEL env > config.yaml ``model:``
|
||||
# (string or ``{default: ...}``). The per-job value is intentionally
|
||||
# re-read from storage every tick so a ``cronjob action=update
|
||||
# model=...`` after a failed run takes effect on the next tick — there
|
||||
# is no in-memory cache.
|
||||
model = job.get("model") or os.getenv("HERMES_MODEL") or ""
|
||||
|
||||
# cron.model / cron.model_provider: a deliberate cron-fleet default
|
||||
# so unattended jobs stop shadowing chat `/model` switches. When an
|
||||
# axis resolves from here, the #44585 drift guard is skipped for that
|
||||
# axis — following cron.model is explicit, not drift.
|
||||
_cron_default_model = ""
|
||||
_cron_default_provider = ""
|
||||
|
||||
# Load config.yaml for model, reasoning, prefill, toolsets, provider routing
|
||||
_cfg = {}
|
||||
_model_cfg = {}
|
||||
|
|
@ -3179,8 +3187,20 @@ def run_job(
|
|||
# Coerce null/missing to {} so a falsy default never
|
||||
# clobbers an already-resolved env value with ``None``.
|
||||
_model_cfg = _cfg.get("model") or {}
|
||||
_cron_cfg_for_model = _cfg.get("cron") or {}
|
||||
if isinstance(_cron_cfg_for_model, dict):
|
||||
_cron_default_model = str(
|
||||
_cron_cfg_for_model.get("model") or ""
|
||||
).strip()
|
||||
_cron_default_provider = str(
|
||||
_cron_cfg_for_model.get("model_provider") or ""
|
||||
).strip()
|
||||
if not job.get("model"):
|
||||
if isinstance(_model_cfg, str):
|
||||
if _cron_default_model:
|
||||
# Cron-fleet default beats the global chat model: it is
|
||||
# the user's explicit "cron runs on this" setting.
|
||||
model = _cron_default_model
|
||||
elif isinstance(_model_cfg, str):
|
||||
model = _model_cfg
|
||||
elif isinstance(_model_cfg, dict):
|
||||
# Mirror the CLI/oneshot resolution: prefer ``default``,
|
||||
|
|
@ -3279,7 +3299,10 @@ def run_job(
|
|||
# circuits that precedence and can resurrect old providers (for
|
||||
# example DeepSeek) for cron jobs that do not pin provider/model.
|
||||
runtime_kwargs = {
|
||||
"requested": job.get("provider"),
|
||||
# Per-job user pin wins; otherwise the cron-fleet default
|
||||
# provider (cron.model_provider); otherwise resolve from
|
||||
# persisted global config.
|
||||
"requested": job.get("provider") or _cron_default_provider or None,
|
||||
# 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
|
||||
|
|
@ -3363,10 +3386,18 @@ def run_job(
|
|||
# Back-compat: an axis with no snapshot (pre-existing jobs, no_agent, or
|
||||
# any axis whose creation-time resolution failed) behaves exactly as
|
||||
# before — the guard never engages for it. Pinned axes are unaffected.
|
||||
#
|
||||
# cron.model / cron.model_provider: an axis resolved from the explicit
|
||||
# cron-fleet default is NOT drift — the user deliberately routed
|
||||
# unpinned cron jobs there, so the guard is skipped for that axis.
|
||||
if cron_model_drift_guard_enabled(_cfg):
|
||||
_drift: list[str] = []
|
||||
_provider_snapshot = (job.get("provider_snapshot") or "").strip().lower()
|
||||
if _provider_snapshot and not (job.get("provider") or "").strip():
|
||||
if (
|
||||
_provider_snapshot
|
||||
and not (job.get("provider") or "").strip()
|
||||
and not _cron_default_provider
|
||||
):
|
||||
_current_provider = str(
|
||||
primary_provider_for_drift or runtime.get("provider") or ""
|
||||
).strip().lower()
|
||||
|
|
@ -3375,7 +3406,11 @@ def run_job(
|
|||
f"provider '{_provider_snapshot}' -> '{_current_provider}'"
|
||||
)
|
||||
_model_snapshot = (job.get("model_snapshot") or "").strip().lower()
|
||||
if _model_snapshot and not (job.get("model") or "").strip():
|
||||
if (
|
||||
_model_snapshot
|
||||
and not (job.get("model") or "").strip()
|
||||
and not _cron_default_model
|
||||
):
|
||||
_current_model = str(primary_model_for_drift or "").strip().lower()
|
||||
if _current_model and _current_model != _model_snapshot:
|
||||
_drift.append(
|
||||
|
|
|
|||
|
|
@ -2911,6 +2911,16 @@ DEFAULT_CONFIG = {
|
|||
# jobs from silently inheriting a paid default. Set to false only when
|
||||
# jobs should deliberately track changing global inference defaults.
|
||||
"model_drift_guard": True,
|
||||
# Default inference model for cron jobs (Axis A — WHAT model an
|
||||
# agent job runs on). Resolution at fire time: per-job user pin >
|
||||
# cron.model > global model.default. When set, unpinned jobs follow
|
||||
# this deliberately, so the #44585 model-drift fail-closed guard does
|
||||
# not engage for the model axis — cron spend no longer shadows chat
|
||||
# `/model` switches. Empty string = fall through to model.default.
|
||||
"model": "",
|
||||
# Inference provider paired with cron.model (NOT the scheduler
|
||||
# provider below). Empty string = resolve from global config.
|
||||
"model_provider": "",
|
||||
# Active cron SCHEDULER provider (Axis B — the trigger that decides
|
||||
# WHEN a due job fires). Empty string = the built-in in-process 60s
|
||||
# ticker (default). Name an installed provider (plugins/cron_providers/<name>/ or
|
||||
|
|
@ -8894,6 +8904,31 @@ def cron_model_drift_guard_enabled(
|
|||
return cron_config.get("model_drift_guard", True) is not False
|
||||
|
||||
|
||||
def _cron_fleet_default_covers_axis(
|
||||
axis: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""True when cron.model / cron.model_provider covers *axis*.
|
||||
|
||||
An axis covered by the explicit cron-fleet default no longer follows the
|
||||
global model/provider at fire time, so the drift guard never engages for
|
||||
it and switch-time warnings would be false alarms.
|
||||
"""
|
||||
if config is None:
|
||||
try:
|
||||
config = load_config()
|
||||
except Exception:
|
||||
return False
|
||||
if not isinstance(config, dict):
|
||||
return False
|
||||
cron_config = config.get("cron")
|
||||
if not isinstance(cron_config, dict):
|
||||
return False
|
||||
key = "model" if axis == "model" else "model_provider"
|
||||
value = cron_config.get(key)
|
||||
return isinstance(value, str) and bool(value.strip())
|
||||
|
||||
|
||||
def _load_cron_jobs_for_config_warning() -> List[Dict[str, Any]]:
|
||||
"""Best-effort read of the active profile's cron jobs database.
|
||||
|
||||
|
|
@ -8925,6 +8960,12 @@ def warn_unpinned_cron_jobs_after_model_config_change(
|
|||
return
|
||||
if not cron_model_drift_guard_enabled(config):
|
||||
return
|
||||
# A cron-fleet default covering this axis (cron.model /
|
||||
# cron.model_provider) means unpinned jobs no longer follow the global
|
||||
# value at all — the drift guard will not engage, so warning here would
|
||||
# be a false alarm.
|
||||
if _cron_fleet_default_covers_axis(axis, config):
|
||||
return
|
||||
|
||||
new_value = str(value or "").strip().lower()
|
||||
if not new_value:
|
||||
|
|
|
|||
|
|
@ -349,6 +349,8 @@ def cron_create(args):
|
|||
skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
|
||||
script=getattr(args, "script", None),
|
||||
workdir=getattr(args, "workdir", None),
|
||||
model=getattr(args, "model", None),
|
||||
provider=getattr(args, "model_provider", None),
|
||||
no_agent=getattr(args, "no_agent", False) or None,
|
||||
)
|
||||
if not result.get("success"):
|
||||
|
|
@ -412,6 +414,8 @@ def cron_edit(args):
|
|||
skills=final_skills,
|
||||
script=getattr(args, "script", None),
|
||||
workdir=getattr(args, "workdir", None),
|
||||
model=getattr(args, "model", None),
|
||||
provider=getattr(args, "model_provider", None),
|
||||
no_agent=getattr(args, "no_agent", None),
|
||||
)
|
||||
if not result.get("success"):
|
||||
|
|
|
|||
|
|
@ -70,6 +70,19 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
|
|||
"--workdir",
|
||||
help="Absolute path for the job to run from. Injects AGENTS.md / CLAUDE.md / .cursorrules from that directory and uses it as the cwd for terminal/file/code_exec tools. Omit to preserve old behaviour (no project context files).",
|
||||
)
|
||||
cron_create.add_argument(
|
||||
"--model",
|
||||
help=(
|
||||
"Pin this job to a specific inference model (user-owned; the "
|
||||
"agent's cronjob tool cannot set this). Omit to follow "
|
||||
"cron.model / model.default from config.yaml."
|
||||
),
|
||||
)
|
||||
cron_create.add_argument(
|
||||
"--provider",
|
||||
dest="model_provider",
|
||||
help="Inference provider paired with --model (e.g. 'openrouter', 'nous').",
|
||||
)
|
||||
|
||||
# cron edit
|
||||
cron_edit = cron_subparsers.add_parser(
|
||||
|
|
@ -134,6 +147,19 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
|
|||
"--workdir",
|
||||
help="Absolute path for the job to run from (injects AGENTS.md etc. and sets terminal cwd). Pass empty string to clear.",
|
||||
)
|
||||
cron_edit.add_argument(
|
||||
"--model",
|
||||
help=(
|
||||
"Pin this job to a specific inference model (user-owned; the "
|
||||
"agent's cronjob tool cannot set this). Pass empty string to "
|
||||
"clear the pin and follow cron.model / model.default."
|
||||
),
|
||||
)
|
||||
cron_edit.add_argument(
|
||||
"--provider",
|
||||
dest="model_provider",
|
||||
help="Inference provider paired with --model. Pass empty string to clear.",
|
||||
)
|
||||
|
||||
# lifecycle actions
|
||||
cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job")
|
||||
|
|
|
|||
|
|
@ -250,14 +250,21 @@ def _run_with_current_provider_and_model(
|
|||
tmp_path,
|
||||
*,
|
||||
model_drift_guard=None,
|
||||
cron_model=None,
|
||||
cron_model_provider=None,
|
||||
):
|
||||
"""Drive run_job with resolved provider pinned and config.yaml model.default
|
||||
set to ``current_model`` (the unpinned-model fire-time source)."""
|
||||
config_yaml = f"model:\n default: {current_model}\n"
|
||||
cron_lines = []
|
||||
if model_drift_guard is not None:
|
||||
config_yaml += (
|
||||
f"cron:\n model_drift_guard: {str(model_drift_guard).lower()}\n"
|
||||
)
|
||||
cron_lines.append(f" model_drift_guard: {str(model_drift_guard).lower()}")
|
||||
if cron_model is not None:
|
||||
cron_lines.append(f" model: {cron_model}")
|
||||
if cron_model_provider is not None:
|
||||
cron_lines.append(f" model_provider: {cron_model_provider}")
|
||||
if cron_lines:
|
||||
config_yaml += "cron:\n" + "\n".join(cron_lines) + "\n"
|
||||
(tmp_path / "config.yaml").write_text(config_yaml)
|
||||
fake_db = MagicMock()
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
|
|
@ -366,6 +373,130 @@ class TestModelDriftGuard:
|
|||
assert error is None
|
||||
|
||||
|
||||
class TestCronFleetDefaultModel:
|
||||
"""cron.model / cron.model_provider — an explicit cron-fleet default is
|
||||
NOT drift: unpinned jobs run on it and the #44585 guard stays quiet for
|
||||
the covered axis."""
|
||||
|
||||
def test_cron_model_skips_model_drift_guard_and_is_used(self, tmp_path):
|
||||
# Snapshot says old free model, global default swapped to a premium
|
||||
# model — but cron.model is set, so the job deliberately follows it.
|
||||
job = _base_job(
|
||||
provider_snapshot="openrouter",
|
||||
model_snapshot="llama-3.3-70b-instruct:free",
|
||||
)
|
||||
success, output, final_response, error, agent_constructed = \
|
||||
_run_with_current_provider_and_model(
|
||||
job,
|
||||
"openrouter",
|
||||
"claude-fable-5",
|
||||
tmp_path,
|
||||
cron_model="qwen-2.5-7b:free",
|
||||
)
|
||||
assert agent_constructed is True
|
||||
assert success is True
|
||||
assert final_response == "ok"
|
||||
|
||||
def test_cron_model_beats_global_default_for_unpinned_job(self, tmp_path):
|
||||
job = _base_job(
|
||||
provider_snapshot="openrouter",
|
||||
model_snapshot="llama-3.3-70b-instruct:free",
|
||||
)
|
||||
captured = {}
|
||||
|
||||
config_yaml = (
|
||||
"model:\n default: claude-fable-5\n"
|
||||
"cron:\n model: qwen-2.5-7b:free\n"
|
||||
)
|
||||
(tmp_path / "config.yaml").write_text(config_yaml)
|
||||
fake_db = MagicMock()
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"api_key": "test-key",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._get_hermes_home", return_value=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)
|
||||
agent_kwargs = mock_agent_cls.call_args.kwargs
|
||||
|
||||
assert captured.get("target_model") == "qwen-2.5-7b:free"
|
||||
assert agent_kwargs.get("model") == "qwen-2.5-7b:free"
|
||||
|
||||
def test_per_job_pin_still_beats_cron_model(self, tmp_path):
|
||||
job = _base_job(
|
||||
provider_snapshot="openrouter",
|
||||
model_snapshot="old-model",
|
||||
model="my-pinned-model",
|
||||
)
|
||||
success, output, final_response, error, agent_constructed = \
|
||||
_run_with_current_provider_and_model(
|
||||
job,
|
||||
"openrouter",
|
||||
"claude-fable-5",
|
||||
tmp_path,
|
||||
cron_model="qwen-2.5-7b:free",
|
||||
)
|
||||
assert agent_constructed is True
|
||||
assert success is True
|
||||
|
||||
def test_cron_model_provider_skips_provider_drift_guard(self, tmp_path):
|
||||
# Provider snapshot differs from the current resolution, but the user
|
||||
# explicitly routed cron via cron.model_provider — not drift.
|
||||
job = _base_job(
|
||||
provider_snapshot="deepseek",
|
||||
model_snapshot="some-model",
|
||||
)
|
||||
success, output, final_response, error, agent_constructed = \
|
||||
_run_with_current_provider_and_model(
|
||||
job,
|
||||
"nous",
|
||||
"some-model",
|
||||
tmp_path,
|
||||
cron_model="some-model",
|
||||
cron_model_provider="nous",
|
||||
)
|
||||
assert agent_constructed is True
|
||||
assert success is True
|
||||
|
||||
def test_cron_model_does_not_unblock_provider_axis(self, tmp_path):
|
||||
# cron.model only covers the MODEL axis: a provider drift with no
|
||||
# cron.model_provider set must still fail closed.
|
||||
job = _base_job(
|
||||
provider_snapshot="openrouter",
|
||||
model_snapshot="some-model",
|
||||
)
|
||||
success, output, final_response, error, agent_constructed = \
|
||||
_run_with_current_provider_and_model(
|
||||
job,
|
||||
"nous",
|
||||
"whatever",
|
||||
tmp_path,
|
||||
cron_model="some-model",
|
||||
)
|
||||
assert agent_constructed is False
|
||||
assert success is False
|
||||
blob = f"{error}\n{output}".lower()
|
||||
assert "provider 'openrouter' -> 'nous'" in blob
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -573,6 +573,34 @@ class TestCronModelDriftConfigWarning:
|
|||
assert "Set model.default = new-model" in captured.out
|
||||
assert "fail closed" not in captured.out
|
||||
|
||||
def test_cron_fleet_default_suppresses_model_axis_warning(
|
||||
self,
|
||||
_isolated_hermes_home,
|
||||
capsys,
|
||||
):
|
||||
"""With cron.model set, unpinned jobs follow the fleet default, not the
|
||||
global model — a model.default change cannot trip the guard, so no
|
||||
warning should be printed."""
|
||||
_write_cron_jobs(
|
||||
_isolated_hermes_home,
|
||||
[
|
||||
{
|
||||
"id": "model-drift-job",
|
||||
"enabled": True,
|
||||
"model": None,
|
||||
"model_snapshot": "old-model",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
set_config_value("cron.model", "cron-fleet-model")
|
||||
capsys.readouterr()
|
||||
set_config_value("model.default", "new-model")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Set model.default = new-model" in captured.out
|
||||
assert "fail closed" not in captured.out
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("configured_value", "expected"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -530,54 +530,82 @@ class TestUnifiedCronjobTool:
|
|||
|
||||
|
||||
# =========================================================================
|
||||
# Per-job model/provider override resolution
|
||||
# Agent-facing surface: per-job model pins are user-owned
|
||||
# =========================================================================
|
||||
|
||||
from tools.cronjob_tools import _resolve_model_override # noqa: E402
|
||||
|
||||
class TestAgentCannotSetModelPin:
|
||||
"""Per-job inference pins are user-owned (dashboard / `hermes cron`
|
||||
--model / hand-edited jobs). The agent-facing tool schema must not expose
|
||||
model/provider/base_url, and the registered handler must ignore them even
|
||||
if a model hallucinates the old parameters."""
|
||||
|
||||
class TestResolveModelOverride:
|
||||
"""`_resolve_model_override` must not silently hijack a job that meant to
|
||||
use a configured custom endpoint (e.g. ``providers.custom`` → cliproxy).
|
||||
Regression for cron jobs with ``provider: "custom"`` falling back to codex.
|
||||
"""
|
||||
def test_schema_has_no_inference_pin_params(self):
|
||||
from tools.cronjob_tools import CRONJOB_SCHEMA
|
||||
|
||||
def test_keeps_bare_custom_when_a_named_entry_exists(self, monkeypatch):
|
||||
import hermes_cli.runtime_provider as rp_mod
|
||||
props = CRONJOB_SCHEMA["parameters"]["properties"]
|
||||
assert "model" not in props
|
||||
assert "provider" not in props
|
||||
assert "base_url" not in props
|
||||
|
||||
monkeypatch.setattr(rp_mod, "has_named_custom_provider", lambda name: True)
|
||||
provider, model = _resolve_model_override(
|
||||
{"provider": "custom", "model": "gpt-5.4"}
|
||||
def test_handler_ignores_hallucinated_model_args(self):
|
||||
from cron.jobs import get_job
|
||||
from tools.registry import registry
|
||||
|
||||
result = json.loads(
|
||||
registry.dispatch(
|
||||
"cronjob",
|
||||
{
|
||||
"action": "create",
|
||||
"prompt": "Check",
|
||||
"schedule": "every 1h",
|
||||
"model": {"provider": "openrouter", "model": "openai/gpt-4.1"},
|
||||
"provider": "openrouter",
|
||||
"base_url": "http://127.0.0.1:4000/v1",
|
||||
},
|
||||
)
|
||||
)
|
||||
assert provider == "custom"
|
||||
assert model == "gpt-5.4"
|
||||
assert result["success"] is True
|
||||
stored = get_job(result["job_id"])
|
||||
assert stored is not None
|
||||
assert stored["model"] is None
|
||||
assert stored["provider"] is None
|
||||
assert stored["base_url"] is None
|
||||
|
||||
def test_pins_main_provider_when_bare_custom_unresolvable(self, monkeypatch):
|
||||
import hermes_cli.config as cfg_mod
|
||||
import hermes_cli.runtime_provider as rp_mod
|
||||
def test_handler_update_leaves_user_pin_untouched(self):
|
||||
"""An update through the agent handler must not clear or change a
|
||||
user-set pin (grandfathered agent-era pins included)."""
|
||||
from cron.jobs import get_job
|
||||
from tools.registry import registry
|
||||
|
||||
monkeypatch.setattr(rp_mod, "has_named_custom_provider", lambda name: False)
|
||||
monkeypatch.setattr(
|
||||
cfg_mod, "load_config", lambda: {"model": {"provider": "openai-codex"}}
|
||||
created = json.loads(
|
||||
cronjob(
|
||||
action="create",
|
||||
prompt="Check",
|
||||
schedule="every 1h",
|
||||
model="anthropic/claude-sonnet-4",
|
||||
provider="anthropic",
|
||||
)
|
||||
)
|
||||
provider, model = _resolve_model_override(
|
||||
{"provider": "custom", "model": "gpt-5.4"}
|
||||
)
|
||||
# No matching custom entry → fall back to pinning the main provider.
|
||||
assert provider == "openai-codex"
|
||||
assert model == "gpt-5.4"
|
||||
job_id = created["job_id"]
|
||||
|
||||
def test_keeps_explicit_custom_name_unchanged(self, monkeypatch):
|
||||
import hermes_cli.runtime_provider as rp_mod
|
||||
|
||||
# Even if the resolver claims no entry, the canonical "custom:<name>"
|
||||
# form is never stripped or pinned.
|
||||
monkeypatch.setattr(rp_mod, "has_named_custom_provider", lambda name: False)
|
||||
provider, model = _resolve_model_override(
|
||||
{"provider": "custom:cliproxy", "model": "gpt-5.4"}
|
||||
updated = json.loads(
|
||||
registry.dispatch(
|
||||
"cronjob",
|
||||
{
|
||||
"action": "update",
|
||||
"job_id": job_id,
|
||||
"name": "renamed",
|
||||
"model": {"model": "openai/gpt-4.1"},
|
||||
},
|
||||
)
|
||||
)
|
||||
assert provider == "custom:cliproxy"
|
||||
assert model == "gpt-5.4"
|
||||
assert updated["success"] is True
|
||||
stored = get_job(job_id)
|
||||
assert stored is not None
|
||||
assert stored["model"] == "anthropic/claude-sonnet-4"
|
||||
assert stored["provider"] == "anthropic"
|
||||
assert stored["name"] == "renamed"
|
||||
|
||||
|
||||
class TestLocalDeliveryNotice:
|
||||
|
|
|
|||
|
|
@ -373,48 +373,6 @@ def _canonical_skills(skill: Optional[str] = None, skills: Optional[Any] = None)
|
|||
|
||||
|
||||
|
||||
def _resolve_model_override(model_obj: Optional[Dict[str, Any]]) -> tuple:
|
||||
"""Resolve a model override object into (provider, model) for job storage.
|
||||
|
||||
If provider is omitted, pins the current main provider from config so the
|
||||
job doesn't drift when the user later changes their default via hermes model.
|
||||
|
||||
Returns (provider_str_or_none, model_str_or_none).
|
||||
"""
|
||||
if not model_obj or not isinstance(model_obj, dict):
|
||||
return (None, None)
|
||||
model_name = (model_obj.get("model") or "").strip() or None
|
||||
provider_name = (model_obj.get("provider") or "").strip() or None
|
||||
# Bare "custom" is usually an incomplete spec — the canonical form is
|
||||
# "custom:<name>" matching a custom_providers entry, and LLMs frequently
|
||||
# supply the bare type because the schema does not advertise the
|
||||
# ":<name>" suffix. It is only a problem when it can't resolve at runtime:
|
||||
# a user may literally name a ``providers.custom`` (or custom_providers
|
||||
# "custom") entry, in which case the job should keep ``provider="custom"``
|
||||
# and run against that endpoint. Only when no such entry exists do we treat
|
||||
# the bare value as "no provider supplied" and pin the current main
|
||||
# provider below — otherwise pinning to ``model.provider`` (e.g. codex)
|
||||
# silently hijacks a job that meant to use the configured custom endpoint.
|
||||
if provider_name == "custom":
|
||||
try:
|
||||
from hermes_cli.runtime_provider import has_named_custom_provider
|
||||
if not has_named_custom_provider("custom"):
|
||||
provider_name = None
|
||||
except Exception:
|
||||
provider_name = None
|
||||
if model_name and not provider_name:
|
||||
# Pin to the current main provider so the job is stable
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
provider_name = model_cfg.get("provider") or None
|
||||
except Exception:
|
||||
pass # Best-effort; provider stays None
|
||||
return (provider_name, model_name)
|
||||
|
||||
|
||||
def _normalize_optional_job_value(value: Optional[Any], *, strip_trailing_slash: bool = False) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
|
|
@ -1028,21 +986,6 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
|
|||
"items": {"type": "string"},
|
||||
"description": "Optional ordered list of skill names to load before executing the cron prompt. On update, pass an empty array to clear attached skills."
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"description": "Optional per-job model override. If provider is omitted, the current main provider is pinned at creation time so the job stays stable.",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "Provider name (e.g. 'openrouter', 'anthropic', or 'custom:<name>' for a provider defined in custom_providers config — always include the ':<name>' suffix, never pass the bare 'custom'). Omit to use and pin the current provider."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model name (e.g. 'anthropic/claude-sonnet-4', 'claude-sonnet-4')"
|
||||
}
|
||||
},
|
||||
"required": ["model"]
|
||||
},
|
||||
"script": {
|
||||
"type": "string",
|
||||
"description": f"Optional path to a script that runs each tick. In the default mode its stdout is injected into the agent's prompt as context (data-collection / change-detection pattern). With no_agent=True, the script IS the job and its stdout is delivered verbatim (classic watchdog pattern). Relative paths resolve under {display_hermes_home()}/scripts/. ``.sh``/``.bash`` extensions run via bash, everything else via Python. On update, pass empty string to clear."
|
||||
|
|
@ -1126,7 +1069,7 @@ registry.register(
|
|||
name="cronjob",
|
||||
toolset="cronjob",
|
||||
schema=CRONJOB_SCHEMA,
|
||||
handler=lambda args, **kw: (lambda _mo=_resolve_model_override(args.get("model")): cronjob(
|
||||
handler=lambda args, **kw: cronjob(
|
||||
action=args.get("action", ""),
|
||||
job_id=args.get("job_id"),
|
||||
prompt=args.get("prompt"),
|
||||
|
|
@ -1137,9 +1080,11 @@ registry.register(
|
|||
include_disabled=args.get("include_disabled", True),
|
||||
skill=args.get("skill"),
|
||||
skills=args.get("skills"),
|
||||
model=_mo[1],
|
||||
provider=_mo[0] or args.get("provider"),
|
||||
base_url=args.get("base_url"),
|
||||
# model / provider / base_url are intentionally NOT read from the
|
||||
# agent's arguments: per-job inference pins are user-owned (dashboard,
|
||||
# `hermes cron create/edit --model`, or hand-edited jobs). The agent
|
||||
# must not be able to point unattended spend at a different model.
|
||||
# Programmatic callers of cronjob() itself retain the parameters.
|
||||
reason=args.get("reason"),
|
||||
script=args.get("script"),
|
||||
context_from=args.get("context_from"),
|
||||
|
|
@ -1147,7 +1092,7 @@ registry.register(
|
|||
workdir=args.get("workdir"),
|
||||
no_agent=args.get("no_agent"),
|
||||
task_id=kw.get("task_id"),
|
||||
))(),
|
||||
),
|
||||
check_fn=check_cronjob_requirements,
|
||||
emoji="⏰",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,13 @@ Cron jobs can:
|
|||
All of this is available to Hermes itself through the `cronjob` tool, so you can create, pause, edit, and remove jobs by asking in plain language — no CLI required.
|
||||
|
||||
:::tip
|
||||
At creation, an unpinned job (one you don't give an explicit `provider`/`model`) follows the global default selected by `hermes model` — and Hermes **snapshots** that provider and model on the job. If the global default later changes, the job **fails closed**: it skips the run, makes no inference call, and sends an alert telling you to pin the provider/model explicitly (`cronjob action=update job_id=… provider=… model=…`) to proceed. This prevents an unattended job from silently inheriting a switch to a paid provider/model and spending money you didn't intend (#44585). To make a job deliberately track your global default, pin it to the new values after changing them. Operators who intentionally maintain large fleets of unpinned jobs can [disable the drift guard](#letting-unpinned-jobs-track-global-defaults). `hermes setup --portal` is the lowest-friction option for unattended runs since OAuth refresh is automatic. See [Nous Portal](/integrations/nous-portal).
|
||||
**Which model does a cron job run on?** Resolution at fire time is: per-job pin → `cron.model` in `config.yaml` → the global default from `hermes model`.
|
||||
|
||||
- **Per-job pin** — set by *you* via the dashboard, `hermes cron create/edit --model … --provider …`, or by editing `~/.hermes/cron/jobs.json`. Once set, it sticks until you change it. The agent's `cronjob` tool cannot set or change per-job models — inference pins are user-owned.
|
||||
- **`cron.model` / `cron.model_provider`** — a cron-fleet default: every unpinned job runs on this model, independent of your chat model. Set it once (`hermes config set cron.model <name>`) and switching your chat model with `hermes model` or `/model` never touches your cron fleet.
|
||||
- **Global default** — only when neither of the above is set does a job follow `hermes model`. In this case Hermes **snapshots** the provider and model at creation, and if the global default later changes the job **fails closed**: it skips the run, makes no inference call, and alerts you to pin the provider/model explicitly (#44585). This prevents an unattended job from silently inheriting a switch to a paid provider/model. Setting `cron.model` (or a per-job pin) is the deliberate way to route cron spend, and the drift guard does not engage for an axis covered by it. Operators who instead want unpinned jobs to track the changing global default can [disable the drift guard](#letting-unpinned-jobs-track-global-defaults).
|
||||
|
||||
`hermes setup --portal` is the lowest-friction option for unattended runs since OAuth refresh is automatic. See [Nous Portal](/integrations/nous-portal).
|
||||
:::
|
||||
|
||||
:::warning
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue