Skip to content
Closed
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
4 changes: 4 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ def _run_and_exit_oneshot(
provider: object = None,
toolsets: object = None,
usage_file: object = None,
skills: object = None,
) -> None:
try:
from hermes_cli.oneshot import run_oneshot
Expand All @@ -181,6 +182,7 @@ def _run_and_exit_oneshot(
provider=provider,
toolsets=toolsets,
usage_file=usage_file,
skills=skills,
)
except KeyboardInterrupt:
rc = 130
Expand Down Expand Up @@ -14760,6 +14762,7 @@ def _try_termux_fast_cli_launch() -> bool:
provider=getattr(args, "provider", None),
toolsets=getattr(args, "toolsets", None),
usage_file=getattr(args, "usage_file", None),
skills=getattr(args, "skills", None),
)

if (args.resume or args.continue_last) and args.command is None:
Expand Down Expand Up @@ -17282,6 +17285,7 @@ def _progress(info):
provider=getattr(args, "provider", None),
toolsets=getattr(args, "toolsets", None),
usage_file=getattr(args, "usage_file", None),
skills=getattr(args, "skills", None),
)

# Handle top-level --resume / --continue as shortcut to chat
Expand Down
43 changes: 43 additions & 0 deletions hermes_cli/oneshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ def run_oneshot(
provider: Optional[str] = None,
toolsets: object = None,
usage_file: Optional[str] = None,
skills: object = None,
) -> int:
"""Execute a single prompt and print only the final content block.

Expand All @@ -187,6 +188,8 @@ def run_oneshot(
cost, token counts, model, api_calls) is written there after the
run — even when the run fails — so pipelines can account for
spend per invocation.
skills: Optional comma-separated string or iterable of skill names
to preload into the system prompt.

Returns the exit code. The caller owns process termination.
"""
Expand Down Expand Up @@ -248,6 +251,7 @@ def run_oneshot(
provider=provider,
toolsets=explicit_toolsets,
use_config_toolsets=use_config_toolsets,
skills=skills,
)
except BaseException as exc: # noqa: BLE001
# Capture anything that escapes the agent (including OSError
Expand Down Expand Up @@ -310,12 +314,35 @@ def _create_session_db_for_oneshot():
return None


def _parse_skills_for_oneshot(skills: object) -> list[str]:
"""Normalize skills argument into a list of skill identifiers."""
if not skills:
return []
if isinstance(skills, str):
raw_values = [skills]
elif isinstance(skills, (list, tuple)):
raw_values = [str(item) for item in skills if item is not None]
else:
raw_values = [str(skills)]
parsed = []
seen = set()
for raw in raw_values:
for part in raw.split(","):
normalized = part.strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
parsed.append(normalized)
return parsed


def _run_agent(
prompt: str,
model: Optional[str] = None,
provider: Optional[str] = None,
toolsets: object = None,
use_config_toolsets: bool = True,
skills: object = None,
) -> tuple[str, dict]:
"""Build an AIAgent exactly like a normal CLI chat turn would, then
run a single conversation. Returns ``(final_response, run_result)``."""
Expand Down Expand Up @@ -396,6 +423,21 @@ def _run_agent(
toolsets_list = sorted(_get_platform_tools(cfg, "cli"))

session_db = _create_session_db_for_oneshot()

# Preload skills into system prompt (#71759)
skills_prompt = ""
if skills:
try:
from agent.skill_commands import build_preloaded_skills_prompt
parsed_skills = _parse_skills_for_oneshot(skills)
if parsed_skills:
skills_prompt, _loaded, _missing = build_preloaded_skills_prompt(
parsed_skills,
task_id=None,
)
except Exception:
pass # Best-effort: don't crash oneshot over skill loading

# The try spans agent construction (not just ``chat``) so the SQLite store
# opened above is always closed — including when ``AIAgent(...)`` itself
# raises on a provider/config error. The one-shot exit path hard-exits via
Expand All @@ -420,6 +462,7 @@ def _run_agent(
session_db=session_db,
credential_pool=runtime.get("credential_pool"),
fallback_model=_fb or None,
ephemeral_system_prompt=skills_prompt or None,
# Interactive callbacks are intentionally NOT wired beyond this
# one. In oneshot mode there's no user sitting at a terminal:
# - clarify → returns a synthetic "pick a default" instruction
Expand Down
16 changes: 16 additions & 0 deletions tests/tools/test_cronjob_run_immediate.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@ def test_execute_job_now_bails_without_claim(self):
assert res["success"] is False
m_run.assert_not_called()

def test_execute_job_now_handles_oneshot_removal(self):
"""A one-shot job removed by mark_job_run should report success (#71760).

For finite one-shot jobs (repeat.times=1), mark_job_run removes the
job from the store when completed >= times. get_job returns None in
that case, which previously caused execution_success=False even though
the job ran to completion.
"""
with patch("tools.cronjob_tools.claim_job_for_fire", return_value=True), \
patch("cron.scheduler.run_one_job", return_value=True), \
patch("tools.cronjob_tools.get_job", return_value=None):
res = _execute_job_now(dict(_JOB))
assert res["claimed"] is True
assert res["success"] is True
assert res["error"] is None

def test_execute_job_now_marks_failure_on_exception(self):
"""An exception during fire is captured, marked failed, not propagated."""
with patch("tools.cronjob_tools.claim_job_for_fire", return_value=True), \
Expand Down
13 changes: 12 additions & 1 deletion tools/cronjob_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,18 @@ def _execute_job_now(job: Dict[str, Any]) -> Dict[str, Any]:
# run_one_job records last_run_at/last_status via mark_job_run (which
# also clears the fire claim) and returns True iff it processed the job.
processed = run_one_job(job)
refreshed = get_job(job_id) or {}
refreshed = get_job(job_id)
# For finite one-shot jobs, mark_job_run removes the job from the
# store when completed >= times. get_job returns None in that case,
# so we cannot read last_status — but processed=True means the job
# ran end-to-end successfully. Treat removal as implicit success
# rather than reporting a false "failed" to the operator (#71760).
if refreshed is None:
return {
"claimed": True,
"success": bool(processed),
"error": None,
}
ok = refreshed.get("last_status") == "ok"
return {
"claimed": True,
Expand Down
Loading