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 @@ -142,6 +142,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 @@ -152,6 +153,7 @@ def _run_and_exit_oneshot(
model=model,
provider=provider,
toolsets=toolsets,
skills=skills,
usage_file=usage_file,
)
except KeyboardInterrupt:
Expand Down Expand Up @@ -13121,6 +13123,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 @@ -15274,6 +15277,7 @@ def _export_one(session_id: str):
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 @@ -316,6 +351,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 @@ -395,6 +431,8 @@ def _run_agent(
if toolsets_list is None and use_config_toolsets:
toolsets_list = sorted(_get_platform_tools(cfg, "cli"))

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 @@ -419,6 +457,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
113 changes: 113 additions & 0 deletions tests/hermes_cli/test_tui_resume_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,8 @@ def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod)
"gpt-test",
"--provider",
"openai",
"--skills",
"demo-skill",
"--usage-file",
"usage.json",
],
Expand Down Expand Up @@ -402,6 +404,7 @@ def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod)
"model": "gpt-test",
"provider": "openai",
"toolsets": None,
"skills": ["demo-skill"],
"usage_file": "usage.json",
}

Expand Down Expand Up @@ -608,6 +611,8 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
"hello",
"--toolsets",
"web,terminal",
"--skills",
"demo-skill",
"--usage-file",
"usage.json",
],
Expand Down Expand Up @@ -656,6 +661,7 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
"model": None,
"provider": None,
"toolsets": "web,terminal",
"skills": ["demo-skill"],
"usage_file": "usage.json",
}

Expand Down Expand Up @@ -1635,6 +1641,113 @@ def mod(name, **attrs):
assert captured["prompt"] == "recall this"


def test_oneshot_run_agent_preloads_available_skills_when_some_are_missing(
monkeypatch,
tmp_path,
):
"""A missing skill must not discard other successfully loaded skills."""
from hermes_cli.oneshot import _run_agent
import tools.skills_tool as skills_tool_module

captured = {}

class FakeAgent:
def __init__(self, **kwargs):
captured.update(kwargs)
self.suppress_status_output = False
self.stream_delta_callback = object()
self.tool_gen_callback = object()

def run_conversation(self, prompt, **_kwargs):
captured["prompt"] = prompt
return {"final_response": "ok", "failed": False, "partial": False}

def mod(name, **attrs):
module = types.ModuleType(name)
for key, value in attrs.items():
setattr(module, key, value)
return module

monkeypatch.setitem(sys.modules, "run_agent", mod("run_agent", AIAgent=FakeAgent))
monkeypatch.setitem(
sys.modules,
"hermes_cli.config",
mod("hermes_cli.config", load_config=lambda: {"model": {"default": "m"}}),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.models",
mod(
"hermes_cli.models",
detect_provider_for_model=lambda *_args, **_kwargs: None,
),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.runtime_provider",
mod(
"hermes_cli.runtime_provider",
resolve_runtime_provider=lambda **_kwargs: {
"api_key": "k",
"base_url": "u",
"provider": "p",
"api_mode": "chat_completions",
"credential_pool": None,
},
),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.tools_config",
mod(
"hermes_cli.tools_config",
_get_platform_tools=lambda *_args, **_kwargs: set(),
),
)
skills_root = tmp_path / "skills"
skill_dir = skills_root / "demo-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"""---
name: demo-skill
description: Demo skill.
---

# Demo Skill

Follow the demo instruction.
""",
encoding="utf-8",
)
monkeypatch.setattr(skills_tool_module, "SKILLS_DIR", skills_root)

text, result = _run_agent(
"use the skill",
skills=["demo-skill,missing-skill"],
)

assert text == "ok"
assert not result.get("failed")
assert "demo-skill" in captured["ephemeral_system_prompt"]
assert "Follow the demo instruction." in captured["ephemeral_system_prompt"]
assert captured["prompt"] == "use the skill"


def test_oneshot_rejects_when_all_preloaded_skills_are_missing(
monkeypatch,
tmp_path,
):
from hermes_cli.oneshot import _build_preloaded_skills_prompt
import tools.skills_tool as skills_tool_module

skills_root = tmp_path / "skills"
skills_root.mkdir()
monkeypatch.setattr(skills_tool_module, "SKILLS_DIR", skills_root)

with pytest.raises(ValueError, match=r"Unknown skill\(s\): missing-skill"):
_build_preloaded_skills_prompt(["missing-skill"])


def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
captured = {}
active_path_during_call = None
Expand Down