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
5 changes: 4 additions & 1 deletion hermes_cli/_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ def build_top_level_parser():
"response text to stdout. No banner, no spinner, no tool "
"previews, no session_id line. Tools, memory, rules, and "
"AGENTS.md in the CWD are loaded as normal; approvals are "
"auto-bypassed. Intended for scripts / pipes."
"auto-bypassed. Intended for scripts / pipes. Combine with "
"--resume <id> to chain turns onto one session (an id that "
"doesn't exist yet is created on first use), or --continue to "
"chain onto the most recent / named session."
),
)
# --model / --provider are accepted at the top level so they can pair
Expand Down
35 changes: 35 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,39 @@ def _curses_browse(stdscr):
return None


def _resolve_oneshot_resume(args) -> Optional[str]:
"""Resolve --resume / --continue for oneshot (-z) mode.

``--resume`` is passed through verbatim: oneshot supports create-on-
first-use session ids (callers like the Smith Crafts OS gateway mint
their own stable id and pass it on every turn), so no existence check
happens here — hermes_cli.oneshot loads whatever history the id has.
``--continue`` resolves exactly like interactive chat: by name when a
value is given, otherwise the most recent CLI session; an unresolvable
``--continue`` is an error (there is nothing sensible to chain onto).
"""
resume = (getattr(args, "resume", None) or "").strip() or None
if resume:
return resume
cont = getattr(args, "continue_last", None)
if not cont:
return None
if isinstance(cont, str):
resolved = _resolve_session_by_name_or_id(cont)
if not resolved:
sys.stderr.write(
f"hermes -z: no session found matching '{cont}'. "
"Use 'hermes sessions list' to see available sessions.\n"
)
sys.exit(2)
return resolved
last_id = _resolve_last_session(source="cli")
if not last_id:
sys.stderr.write("hermes -z: no previous CLI session found to continue.\n")
sys.exit(2)
return last_id


def _resolve_last_session(source: str = "cli") -> Optional[str]:
"""Look up the most recently-used session ID for a source."""
db = None
Expand Down Expand Up @@ -12444,6 +12477,7 @@ def _try_termux_fast_cli_launch() -> bool:
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
toolsets=getattr(args, "toolsets", None),
resume=_resolve_oneshot_resume(args),
)
)

Expand Down Expand Up @@ -13877,6 +13911,7 @@ def cmd_sessions(args):
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
toolsets=getattr(args, "toolsets", None),
resume=_resolve_oneshot_resume(args),
)
)

Expand Down
61 changes: 60 additions & 1 deletion hermes_cli/oneshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@
- If only --model given, auto-detect the provider that serves it.
- If only --provider given, error out (ambiguous — caller must pick a model).

Session chaining (--resume / --continue):
- ``hermes --resume <id> -z "..."`` loads the session's prior transcript as
conversation history and appends this turn to the SAME session id — the
one-shot equivalent of resuming an interactive chat.
- If the id does not exist yet, it is created on first use ("create-on-
first-use"): callers that manage their own session keys (the Smith
Crafts OS gateway, cron workers, scripts) can mint a stable id up front
and pass it on every turn without parsing anything back out.
- ``--continue`` (optionally with a session name) resolves to the most
recent / named CLI session, exactly like interactive chat.
- Best-effort: if the SQLite session store is unavailable, the turn still
runs stateless rather than failing.

Env var fallbacks (used when the corresponding arg is not passed):
- HERMES_INFERENCE_MODEL
"""
Expand Down Expand Up @@ -127,6 +140,7 @@ def run_oneshot(
model: Optional[str] = None,
provider: Optional[str] = None,
toolsets: object = None,
resume: Optional[str] = None,
) -> int:
"""Execute a single prompt and print only the final content block.

Expand All @@ -137,6 +151,9 @@ 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.
resume: Optional session id to chain this turn onto. Prior transcript
is loaded as conversation history and this turn is appended to the
same session. Ids that don't exist yet are created on first use.

Returns the exit code. Caller should sys.exit() with the return.
"""
Expand Down Expand Up @@ -189,6 +206,7 @@ def run_oneshot(
provider=provider,
toolsets=explicit_toolsets,
use_config_toolsets=use_config_toolsets,
resume=resume,
)
except BaseException as exc: # noqa: BLE001
# Capture anything that escapes the agent (including OSError
Expand Down Expand Up @@ -247,12 +265,49 @@ def _create_session_db_for_oneshot():
return None


def _load_resume_history(session_db, resume: str) -> tuple[Optional[str], Optional[list]]:
"""Resolve a --resume id and load its transcript for oneshot chaining.

Returns ``(session_id, conversation_history)``. Mirrors the interactive
resume path (cli_agent_setup_mixin): walk the compression chain via
``resolve_resume_session_id``, load messages in conversation format, and
drop ``session_meta`` rows. Unlike interactive resume, an id with no
existing session is NOT an error — it is returned as-is with no history,
so the session is created on first use under the caller's chosen id.
Every step is best-effort: a broken store degrades to a stateless turn.
"""
session_id = (resume or "").strip() or None
if not session_id or session_db is None:
return session_id, None
try:
resolved = session_db.resolve_resume_session_id(session_id)
if resolved:
session_id = resolved
except Exception as exc:
logging.debug("oneshot resume: id resolution failed for %s: %s", session_id, exc)
history: Optional[list] = None
try:
restored = session_db.get_messages_as_conversation(session_id)
restored = [m for m in restored if m.get("role") != "session_meta"]
history = restored or None
except Exception as exc:
logging.debug("oneshot resume: history load failed for %s: %s", session_id, exc)
# Ended sessions accept appends again once reopened; harmless if the
# session is new or already open.
try:
session_db.reopen_session(session_id)
except Exception:
pass
return session_id, history


def _run_agent(
prompt: str,
model: Optional[str] = None,
provider: Optional[str] = None,
toolsets: object = None,
use_config_toolsets: bool = True,
resume: Optional[str] = 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 @@ -333,6 +388,9 @@ def _run_agent(
toolsets_list = sorted(_get_platform_tools(cfg, "cli"))

session_db = _create_session_db_for_oneshot()
# --resume chaining: load the prior transcript and pin the agent to the
# caller's session id so this turn appends to the SAME session.
resume_session_id, conversation_history = _load_resume_history(session_db, resume)
# Read the effective fallback chain from profile config so oneshot workers
# honour the same merge semantics as interactive CLI and gateway sessions.
_fb = get_fallback_chain(cfg)
Expand All @@ -346,6 +404,7 @@ def _run_agent(
enabled_toolsets=toolsets_list,
quiet_mode=True,
platform="cli",
session_id=resume_session_id,
session_db=session_db,
credential_pool=runtime.get("credential_pool"),
fallback_model=_fb or None,
Expand All @@ -369,7 +428,7 @@ def _run_agent(
agent.stream_delta_callback = None
agent.tool_gen_callback = None

result = agent.run_conversation(prompt)
result = agent.run_conversation(prompt, conversation_history=conversation_history)
return (result.get("final_response") or "", result)


Expand Down
97 changes: 97 additions & 0 deletions tests/hermes_cli/test_oneshot_resume.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Tests for `hermes -z --resume` session chaining (hermes_cli.oneshot).

Oneshot historically ignored --resume entirely: every -z call built a fresh
AIAgent with a fresh session, so scripted callers (the Smith Crafts OS
gateway, cron workers) could never chain turns. These tests pin the fixed
contract:

- --resume <existing id> loads the prior transcript as conversation_history
and pins the agent to the SAME session id (walking compression chains).
- --resume <unknown id> is create-on-first-use: no error, no history, the
id is used as-is so the caller can mint stable ids up front.
- A broken session store degrades to a stateless turn, never a failure.
"""

from unittest.mock import MagicMock, patch

from hermes_cli.oneshot import _load_resume_history


class TestLoadResumeHistory:
def test_no_resume_returns_none(self):
assert _load_resume_history(MagicMock(), "") == (None, None)
assert _load_resume_history(MagicMock(), None) == (None, None)

def test_no_db_returns_id_stateless(self):
sid, hist = _load_resume_history(None, "abc123")
assert sid == "abc123"
assert hist is None

def test_existing_session_loads_history_and_resolves_chain(self):
db = MagicMock()
db.resolve_resume_session_id.return_value = "tip_id"
db.get_messages_as_conversation.return_value = [
{"role": "session_meta", "content": "meta"},
{"role": "user", "content": "remember ZEBRA"},
{"role": "assistant", "content": "OK"},
]
sid, hist = _load_resume_history(db, "orig_id")
assert sid == "tip_id"
# session_meta rows are dropped, real turns are kept in order.
assert hist == [
{"role": "user", "content": "remember ZEBRA"},
{"role": "assistant", "content": "OK"},
]
db.get_messages_as_conversation.assert_called_once_with("tip_id")
db.reopen_session.assert_called_once_with("tip_id")

def test_unknown_id_creates_on_first_use(self):
db = MagicMock()
db.resolve_resume_session_id.side_effect = lambda s: s
db.get_messages_as_conversation.return_value = []
sid, hist = _load_resume_history(db, "brand_new_id")
assert sid == "brand_new_id"
assert hist is None

def test_broken_store_degrades_to_stateless(self):
db = MagicMock()
db.resolve_resume_session_id.side_effect = RuntimeError("db locked")
db.get_messages_as_conversation.side_effect = RuntimeError("db locked")
db.reopen_session.side_effect = RuntimeError("db locked")
sid, hist = _load_resume_history(db, "sid")
assert sid == "sid"
assert hist is None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This harness mocks SessionDB and AIAgent, so it cannot prove create-on-first-use or a second independent -z --resume invocation reloads and appends the real SQLite transcript. Please add a temp-HERMES_HOME integration test for that two-invocation path.



class TestRunAgentResumeWiring:
def _run(self, resume, load_result, monkeypatch):
monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
agent = MagicMock()
agent.run_conversation.return_value = {"final_response": "PONG"}
agent_cls = MagicMock(return_value=agent)
with (
patch("hermes_cli.oneshot._create_session_db_for_oneshot", return_value=MagicMock()),
patch("hermes_cli.oneshot._load_resume_history", return_value=load_result),
patch("hermes_cli.oneshot.get_fallback_chain", return_value=None),
patch("hermes_cli.config.load_config", return_value={"model": {"default": "m1", "provider": "p1"}}),
patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value={}),
patch("hermes_cli.tools_config._get_platform_tools", return_value=set()),
patch("run_agent.AIAgent", agent_cls),
):
from hermes_cli.oneshot import _run_agent

response, _result = _run_agent("hi", resume=resume)
return agent_cls, agent, response

def test_resume_pins_session_id_and_seeds_history(self, monkeypatch):
history = [{"role": "user", "content": "remember ZEBRA"}]
agent_cls, agent, response = self._run("sid1", ("sid1", history), monkeypatch)
assert agent_cls.call_args.kwargs["session_id"] == "sid1"
agent.run_conversation.assert_called_once_with("hi", conversation_history=history)
assert response == "PONG"

def test_no_resume_keeps_agent_generated_session(self, monkeypatch):
agent_cls, agent, _ = self._run(None, (None, None), monkeypatch)
assert agent_cls.call_args.kwargs["session_id"] is None
agent.run_conversation.assert_called_once_with("hi", conversation_history=None)
2 changes: 2 additions & 0 deletions tests/hermes_cli/test_tui_resume_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod)
"model": "gpt-test",
"provider": "openai",
"toolsets": None,
"resume": None,
}


Expand Down Expand Up @@ -617,6 +618,7 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
"model": None,
"provider": None,
"toolsets": "web,terminal",
"resume": None,
}


Expand Down