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
1 change: 1 addition & 0 deletions contributors/emails/582149912@qq.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
GarlicGo
5 changes: 5 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ def _run_and_exit_oneshot(
model: object = None,
provider: object = None,
toolsets: object = None,
skills: object = None,
usage_file: object = None,
) -> None:
try:
Expand All @@ -189,6 +190,7 @@ def _run_and_exit_oneshot(
model=model,
provider=provider,
toolsets=toolsets,
skills=skills,
usage_file=usage_file,
)
except KeyboardInterrupt:
Expand Down Expand Up @@ -11944,6 +11946,7 @@ def _try_fast_chat_launch() -> bool:
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
toolsets=getattr(args, "toolsets", None),
skills=getattr(args, "skills", None),
usage_file=getattr(args, "usage_file", None),
)

Expand Down Expand Up @@ -12001,6 +12004,7 @@ def _try_termux_fast_cli_launch() -> bool:
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
toolsets=getattr(args, "toolsets", None),
skills=getattr(args, "skills", None),
usage_file=getattr(args, "usage_file", None),
)

Expand Down Expand Up @@ -13879,6 +13883,7 @@ def _dispatch_sessions(_args, *, sessions_parser=sessions_parser):
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
toolsets=getattr(args, "toolsets", None),
skills=getattr(args, "skills", None),
usage_file=getattr(args, "usage_file", None),
)

Expand Down
39 changes: 39 additions & 0 deletions hermes_cli/oneshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,38 @@ def _normalize_toolsets(toolsets: object = None) -> list[str] | None:
return [item for item in normalized if item] or None


def _normalize_skills(skills: object = None) -> list[str]:
"""Normalize repeated/comma-separated skill flags and preserve order."""
normalized = _normalize_toolsets(skills) or []
return list(dict.fromkeys(normalized))


def _build_preloaded_skills_prompt(skills: object = None) -> str | None:
"""Load requested skills using the same partial-success contract as CLI chat."""
parsed_skills = _normalize_skills(skills)
if not parsed_skills:
return None

from agent.skill_commands import build_preloaded_skills_prompt

skills_prompt, loaded_skills, missing_skills = build_preloaded_skills_prompt(
parsed_skills
)
if missing_skills:
missing_display = ", ".join(missing_skills)
if loaded_skills:
logging.warning(
"Unknown skill(s) requested, skipping: %s. Continuing with: %s. "
"List available skills with `hermes skills list`.",
missing_display,
", ".join(loaded_skills),
)
else:
raise ValueError(f"Unknown skill(s): {missing_display}")

return skills_prompt or None


def _validate_explicit_toolsets(toolsets: object = None) -> tuple[list[str] | None, str | None]:
normalized = _normalize_toolsets(toolsets)
if normalized is None:
Expand Down Expand Up @@ -172,6 +204,7 @@ def run_oneshot(
model: Optional[str] = None,
provider: Optional[str] = None,
toolsets: object = None,
skills: object = None,
usage_file: Optional[str] = None,
) -> int:
"""Execute a single prompt and print only the final content block.
Expand All @@ -183,6 +216,7 @@ def run_oneshot(
provider: Optional provider override. Falls back to config.yaml's
model.provider, then "auto".
toolsets: Optional comma-separated string or iterable of toolsets.
skills: Optional repeated/comma-separated skill identifiers to preload.
usage_file: Optional path; when set, a JSON usage report (estimated
cost, token counts, model, api_calls) is written there after the
run — even when the run fails — so pipelines can account for
Expand Down Expand Up @@ -248,6 +282,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 @@ -325,6 +360,7 @@ def _run_agent(
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 @@ -423,6 +459,8 @@ def _run_agent(
single_query=True,
)

skills_prompt = _build_preloaded_skills_prompt(skills)

session_db = _create_session_db_for_oneshot()
# The try spans agent construction (not just ``chat``) so the SQLite store
# opened above is always closed — including when ``AIAgent(...)`` itself
Expand All @@ -448,6 +486,7 @@ def _run_agent(
session_db=session_db,
credential_pool=runtime.get("credential_pool"),
fallback_model=_fb or None,
ephemeral_system_prompt=skills_prompt,
# 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
57 changes: 57 additions & 0 deletions tests/hermes_cli/test_oneshot_skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Regression tests: -z/--oneshot must honor -s/--skills (#31548, #65119).

The oneshot path builds its AIAgent directly (bypassing HermesCLI), so the
--skills preload has to be forwarded explicitly and injected via
``ephemeral_system_prompt``. These tests pin the forwarding contract and the
partial-success semantics shared with normal CLI chat.
"""

import pytest

from hermes_cli.oneshot import _build_preloaded_skills_prompt, _normalize_skills


class TestNormalizeSkills:
def test_none_and_empty(self):
assert _normalize_skills(None) == []
assert _normalize_skills("") == []
assert _normalize_skills([]) == []

def test_comma_separated_string(self):
assert _normalize_skills("a,b") == ["a", "b"]

def test_repeated_flags_deduped_order_preserved(self):
assert _normalize_skills(["b", "a", "b"]) == ["b", "a"]


class TestBuildPreloadedSkillsPrompt:
def test_no_skills_returns_none(self):
assert _build_preloaded_skills_prompt(None) is None

def test_all_missing_raises(self, monkeypatch):
import agent.skill_commands as sc

monkeypatch.setattr(
sc, "build_preloaded_skills_prompt",
lambda parsed, **kw: ("", [], list(parsed)),
)
with pytest.raises(ValueError, match="Unknown skill"):
_build_preloaded_skills_prompt("not-a-skill")

def test_partial_success_returns_prompt(self, monkeypatch):
import agent.skill_commands as sc

monkeypatch.setattr(
sc, "build_preloaded_skills_prompt",
lambda parsed, **kw: ("PROMPT", ["good"], ["bad"]),
)
assert _build_preloaded_skills_prompt(["good", "bad"]) == "PROMPT"

def test_loaded_prompt_returned(self, monkeypatch):
import agent.skill_commands as sc

monkeypatch.setattr(
sc, "build_preloaded_skills_prompt",
lambda parsed, **kw: ("SKILL CONTENT", ["s"], []),
)
assert _build_preloaded_skills_prompt("s") == "SKILL CONTENT"
Loading