Compare commits

..

7 Commits

Author SHA1 Message Date
92c3eb0ab2 feat(cli): Show profile in cron list
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 1m5s
Part of #334. Displays profile name in job list when set.
2026-04-14 01:31:51 +00:00
3e7eec0b88 feat(cli): Add profile handling to cron_create and cron_edit
Part of #334. Passes profile parameter to cron API in create and edit commands.
2026-04-14 01:31:16 +00:00
3ba2907d37 feat(cli): Add --profile argument to cron create/edit
Part of #334. Adds --profile/-p argument to cron create and edit commands.
2026-04-14 01:30:54 +00:00
4b90f9a7f1 feat(cron): Add profile parameter to cronjob tool
Part of #334. Adds profile field to cronjob tool and passes it to create_job.
2026-04-14 01:29:59 +00:00
de80911ab9 feat(cron): Load profile-specific config.yaml
Part of #334. When job has profile set, loads config.yaml from profiles/PROFILE/config.yaml.
2026-04-14 01:25:49 +00:00
4dcfa11593 feat(cron): Add profile-scoped execution to scheduler
Part of #334. Loads profile-specific .env and config.yaml when job has profile set. Sets HERMES_ACTIVE_PROFILE environment variable.
2026-04-14 01:24:31 +00:00
464d0b89fb feat(cron): Add profile parameter to create_job
Part of #334. Adds profile field to job structure for profile-scoped execution.
2026-04-14 01:22:31 +00:00
5 changed files with 61 additions and 125 deletions

View File

@@ -376,6 +376,7 @@ def create_job(
provider: Optional[str] = None,
base_url: Optional[str] = None,
script: Optional[str] = None,
profile: Optional[str] = None,
) -> Dict[str, Any]:
"""
Create a new cron job.
@@ -395,6 +396,9 @@ def create_job(
script: Optional path to a Python script whose stdout is injected into the
prompt each run. The script runs before the agent turn, and its output
is prepended as context. Useful for data collection / change detection.
profile: Optional profile name for profile-scoped execution. When set, the job
runs with that profile's config.yaml and .env, and HERMES_ACTIVE_PROFILE
is set. Enables parallel execution without cross-contamination.
Returns:
The created job dict
@@ -425,6 +429,8 @@ def create_job(
normalized_base_url = normalized_base_url or None
normalized_script = str(script).strip() if isinstance(script, str) else None
normalized_script = normalized_script or None
normalized_profile = str(profile).strip() if isinstance(profile, str) else None
normalized_profile = normalized_profile or None
label_source = (prompt or (normalized_skills[0] if normalized_skills else None)) or "cron job"
job = {
@@ -455,6 +461,8 @@ def create_job(
# Delivery configuration
"deliver": deliver,
"origin": origin, # Tracks where job was created for "origin" delivery
# Profile configuration
"profile": normalized_profile, # Profile for scoped execution
}
jobs = load_jobs()

View File

@@ -163,68 +163,6 @@ from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_
SILENT_MARKER = "[SILENT]"
SCRIPT_FAILED_MARKER = "[SCRIPT_FAILED]"
# Minimum context-window size (tokens) a model must expose for cron jobs.
# Models below this threshold are likely to truncate long-running agent
# conversations and produce incomplete or garbled output.
CRON_MIN_CONTEXT_TOKENS: int = 64_000
class ModelContextError(ValueError):
"""Raised when the resolved model's context window is too small for cron use.
Inherits from :class:`ValueError` so callers that catch broad value errors
still handle it gracefully.
"""
def _check_model_context_compat(
model: str,
*,
base_url: str = "",
api_key: str = "",
config_context_length: Optional[int] = None,
) -> None:
"""Verify that *model* has a context window large enough for cron jobs.
Args:
model: The model name to check (e.g. ``"claude-opus-4-6"``).
base_url: Optional inference endpoint URL passed through to
:func:`agent.model_metadata.get_model_context_length` for
live-probing local servers.
api_key: Optional API key forwarded to context-length detection.
config_context_length: Explicit override from ``config.yaml``
(``model.context_length``). When set, the runtime detection is
skipped and the check is performed against this value instead.
Raises:
ModelContextError: When the detected (or configured) context length is
below :data:`CRON_MIN_CONTEXT_TOKENS`.
"""
# If the user has pinned a context length in config.yaml, skip probing.
if config_context_length is not None:
return
try:
from agent.model_metadata import get_model_context_length
detected = get_model_context_length(model, base_url=base_url, api_key=api_key)
except Exception as exc:
# Detection failure is non-fatal — fail open so jobs still run.
logger.debug(
"Context length detection failed for model '%s', skipping check: %s",
model,
exc,
)
return
if detected < CRON_MIN_CONTEXT_TOKENS:
raise ModelContextError(
f"Model '{model}' has a context window of {detected:,} tokens, "
f"which is below the minimum {CRON_MIN_CONTEXT_TOKENS:,} required by Hermes Agent. "
f"Set 'model.context_length' in config.yaml to override, or choose a model "
f"with a larger context window."
)
# Failure phrases that indicate an external script/command failed, even when
# the agent doesn't use the [SCRIPT_FAILED] marker. Matched case-insensitively
# against the final response. These are strong signals — agents rarely use
@@ -607,32 +545,8 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
return False, f"Script execution failed: {exc}"
def _build_job_prompt(
job: dict,
*,
runtime_model: Optional[str] = None,
runtime_provider: Optional[str] = None,
) -> str:
"""Build the effective prompt for a cron job, optionally loading one or more skills first.
Args:
job: The cron job configuration dict. Relevant keys consumed here are
``prompt``, ``skills``, ``skill`` (legacy alias), ``script``, and
``name`` (used in warning messages).
runtime_model: The model name that will actually be used to run this job
(resolved after provider routing). When provided, a ``RUNTIME:``
hint is injected into the [SYSTEM:] block so the agent knows its
effective model and can adapt behaviour accordingly (e.g. avoid
vision steps on a text-only model).
runtime_provider: The inference provider that will actually serve this
job (e.g. ``"ollama"``, ``"nous"``, ``"anthropic"``). Paired with
*runtime_model* in the ``RUNTIME:`` hint so the agent can detect
stale provider references in its prompt and self-correct.
Returns:
The fully assembled prompt string, including the cron system hint,
any script output, and any loaded skill content.
"""
def _build_job_prompt(job: dict) -> str:
"""Build the effective prompt for a cron job, optionally loading one or more skills first."""
prompt = job.get("prompt", "")
skills = job.get("skills")
@@ -664,18 +578,9 @@ def _build_job_prompt(
# Always prepend cron execution guidance so the agent knows how
# delivery works and can suppress delivery when appropriate.
_runtime_parts = []
if runtime_model:
_runtime_parts.append(f"MODEL: {runtime_model}")
if runtime_provider:
_runtime_parts.append(f"PROVIDER: {runtime_provider}")
_runtime_clause = (
" ".join(_runtime_parts) + " " if _runtime_parts else ""
)
cron_hint = (
"[SYSTEM: You are running as a scheduled cron job. "
+ _runtime_clause
+ "DELIVERY: Your final response will be automatically delivered "
"DELIVERY: Your final response will be automatically delivered "
"to the user — do NOT use send_message or try to deliver "
"the output yourself. Just produce your report/output as your "
"final response and the system handles the rest. "
@@ -690,21 +595,8 @@ def _build_job_prompt(
"response. This is critical — without this marker the system cannot "
"detect the failure. Examples: "
"\"[SCRIPT_FAILED]: forge.alexanderwhitestone.com timed out\" "
"\"[SCRIPT_FAILED]: script exited with code 1\"."
"\"[SCRIPT_FAILED]: script exited with code 1\".]\\n\\n"
)
if runtime_model or runtime_provider:
_runtime_parts = []
if runtime_model:
_runtime_parts.append(f"model={runtime_model}")
if runtime_provider:
_runtime_parts.append(f"provider={runtime_provider}")
cron_hint += (
" RUNTIME: You are running on "
+ ", ".join(_runtime_parts)
+ ". Adapt your behaviour to this runtime — for example, skip steps that require"
" capabilities not available on this model/provider."
)
cron_hint += "]\n\n"
prompt = cron_hint + prompt
if skills is None:
legacy = job.get("skill")
@@ -775,10 +667,12 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
job_id = job["id"]
job_name = job["name"]
prompt = _build_job_prompt(job)
origin = _resolve_origin(job)
_cron_session_id = f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}"
logger.info("Running job '%s' (ID: %s)", job_name, job_id)
logger.info("Prompt: %s", prompt[:100])
try:
# Inject origin context so the agent's send_message tool knows the chat.
@@ -788,6 +682,26 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
os.environ["HERMES_SESSION_CHAT_ID"] = str(origin["chat_id"])
if origin.get("chat_name"):
os.environ["HERMES_SESSION_CHAT_NAME"] = origin["chat_name"]
# Profile-scoped execution: load profile-specific config
profile = job.get("profile")
if profile:
os.environ["HERMES_ACTIVE_PROFILE"] = profile
profile_dir = _hermes_home / "profiles" / profile
if profile_dir.exists():
# Load profile-specific .env
profile_env = profile_dir / ".env"
if profile_env.exists():
try:
load_dotenv(str(profile_env), override=True, encoding="utf-8")
logger.info("Job '%s': Loaded profile .env from %s", job_id, profile_env)
except Exception as e:
logger.warning("Job '%s': Failed to load profile .env: %s", job_id, e)
# Profile config will be loaded later in the config section
logger.info("Job '%s': Running with profile '%s'", job_id, profile)
else:
logger.warning("Job '%s': Profile directory not found: %s", job_id, profile_dir)
# Re-read .env and config.yaml fresh every run so provider/key
# changes take effect without a gateway restart.
from dotenv import load_dotenv
@@ -806,10 +720,21 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
model = job.get("model") or os.getenv("HERMES_MODEL") or ""
# Load config.yaml for model, reasoning, prefill, toolsets, provider routing
# If profile is set, load profile-specific config
_cfg = {}
try:
import yaml
_cfg_path = str(_hermes_home / "config.yaml")
profile = job.get("profile")
if profile:
profile_cfg_path = _hermes_home / "profiles" / profile / "config.yaml"
if profile_cfg_path.exists():
_cfg_path = str(profile_cfg_path)
logger.info("Job '%s': Loading profile config from %s", job_id, _cfg_path)
else:
_cfg_path = str(_hermes_home / "config.yaml")
logger.debug("Job '%s': Profile config not found, using default: %s", job_id, _cfg_path)
else:
_cfg_path = str(_hermes_home / "config.yaml")
if os.path.exists(_cfg_path):
with open(_cfg_path) as _f:
_cfg = yaml.safe_load(_f) or {}
@@ -886,10 +811,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
raise RuntimeError(message) from exc
from agent.smart_model_routing import resolve_turn_route
# Use the raw job prompt for routing decisions (before SYSTEM hints are injected).
_routing_prompt = job.get("prompt", "")
turn_route = resolve_turn_route(
_routing_prompt,
prompt,
smart_routing,
{
"model": model,
@@ -902,15 +825,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
},
)
# Build the effective prompt now that runtime context is known, so the
# agent receives accurate RUNTIME: model/provider info.
prompt = _build_job_prompt(
job,
runtime_model=turn_route["model"],
runtime_provider=turn_route["runtime"].get("provider"),
)
logger.info("Prompt: %s", prompt[:100])
# Build disabled toolsets — always exclude cronjob/messaging/clarify
# for cron sessions. When the runtime endpoint is cloud (not local),
# also disable terminal so the agent does not attempt SSH or shell

View File

@@ -90,6 +90,10 @@ def cron_list(show_all: bool = False):
print(f" Deliver: {deliver_str}")
if skills:
print(f" Skills: {', '.join(skills)}")
# Show profile if set
profile = job.get("profile")
if profile:
print(color(f" Profile: {profile}", Colors.MAGENTA))
script = job.get("script")
if script:
print(f" Script: {script}")

View File

@@ -4550,6 +4550,10 @@ For more help on a command:
cron_create.add_argument("--repeat", type=int, help="Optional repeat count")
cron_create.add_argument("--skill", dest="skills", action="append", help="Attach a skill. Repeat to add multiple skills.")
cron_create.add_argument("--script", help="Path to a Python script whose stdout is injected into the prompt each run")
cron_create.add_argument(
"--profile", "-p",
help="Profile name for profile-scoped execution (loads profile's config.yaml and .env)"
)
# cron edit
cron_edit = cron_subparsers.add_parser("edit", help="Edit an existing scheduled job")
@@ -4564,6 +4568,10 @@ For more help on a command:
cron_edit.add_argument("--remove-skill", dest="remove_skills", action="append", help="Remove a specific attached skill. Repeatable.")
cron_edit.add_argument("--clear-skills", action="store_true", help="Remove all attached skills from the job")
cron_edit.add_argument("--script", help="Path to a Python script whose stdout is injected into the prompt each run. Pass empty string to clear.")
cron_edit.add_argument(
"--profile", "-p",
help="Set profile for profile-scoped execution"
)
# lifecycle actions
cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job")

View File

@@ -233,6 +233,7 @@ def cronjob(
base_url: Optional[str] = None,
reason: Optional[str] = None,
script: Optional[str] = None,
profile: Optional[str] = None,
task_id: str = None,
) -> str:
"""Unified cron job management tool."""
@@ -270,6 +271,7 @@ def cronjob(
provider=_normalize_optional_job_value(provider),
base_url=_normalize_optional_job_value(base_url, strip_trailing_slash=True),
script=_normalize_optional_job_value(script),
profile=_normalize_optional_job_value(profile),
)
return json.dumps(
{